From fe982f7f95ed9b0ed75c257e0a0b905ff2721ea0 Mon Sep 17 00:00:00 2001 From: m4 Date: Sun, 19 Jul 2026 12:17:18 +0800 Subject: [PATCH] feat: add workspace isolation and administration UI --- .env.example | 51 + .github/workflows/ci.yml | 20 +- CONTRIBUTING.md | 13 +- README.md | 11 + docs/conversation-workspace-isolation.md | 1331 +++++++++++ docs/office-preview.md | 31 + docs/schemas/fixtures/accepted/confirmed.json | 33 + docs/schemas/fixtures/accepted/unknown.json | 33 + .../fixtures/identity/workspace-posix.json | 7 + .../identity/workspace-root-and-symlink.json | 8 + .../fixtures/identity/workspace-unicode.json | 7 + .../fixtures/identity/workspace-windows.json | 20 + .../fixtures/metadata/async-subagent.json | 15 + docs/schemas/fixtures/metadata/memory.json | 10 + .../fixtures/metadata/new-message.json | 8 + docs/schemas/fixtures/metadata/resume.json | 19 + .../schemas/fixtures/projection/conflict.json | 9 + .../projection/conflicting-confirmed.json | 33 + .../fixtures/projection/idempotency.json | 6 + docs/schemas/fixtures/projection/unknown.json | 6 + .../fixtures/providers/compatibility.json | 65 + docs/schemas/fixtures/query/bigint.json | 5 + .../fixtures/rejected/unsafe-token-sum.json | 5 + docs/schemas/fixtures/spool/ack-crash.json | 5 + docs/schemas/implementation-evidence.md | 52 + .../provider-reconciliation-capabilities.md | 27 + docs/schemas/provider-usage-compatibility.md | 22 + docs/schemas/usage-api-v1.md | 35 + docs/schemas/usage-event-v1.schema.json | 155 ++ docs/schemas/usage-spool-v1.md | 17 + docs/token统计方案.md | 1267 ++++++++++ docs/webui-approval-modes.md | 543 +++++ next.config.ts | 7 + package-lock.json | 2058 ++++++++++++++++- package.json | 11 +- scripts/assemble-standalone.mjs | 33 +- scripts/verify-standalone.mjs | 32 + scripts/verify-workspace-isolation.mjs | 48 + src/app/api/config/route.ts | 34 + .../[threadId]/async-tasks/[taskId]/route.ts | 76 + .../[threadId]/async-tasks/route.ts | 100 + .../[threadId]/file-state/route.ts | 28 + src/app/api/conversations/[threadId]/route.ts | 132 ++ .../[threadId]/runs/[runId]/cancel/route.ts | 22 + .../[threadId]/runs/[runId]/route.ts | 40 + .../[threadId]/runs/[runId]/stream/route.ts | 68 + .../[threadId]/runs/route.test.ts | 256 ++ .../conversations/[threadId]/runs/route.ts | 244 ++ src/app/api/conversations/route.ts | 126 + src/app/api/default-model/route.ts | 12 + src/app/api/deployment/assistant/route.ts | 36 + src/app/api/evosci-config/route.ts | 34 - src/app/api/models/route.ts | 24 +- src/app/api/provider-actions/route.ts | 12 + src/app/api/provider-profiles/route.ts | 20 + src/app/api/scheduled-tasks/route.ts | 198 ++ src/app/api/usage/calls/route.ts | 54 + src/app/api/usage/capabilities/route.ts | 24 + src/app/api/usage/events/route.ts | 75 + src/app/api/usage/routes.test.ts | 124 + src/app/api/usage/sources/heartbeat/route.ts | 121 + src/app/api/usage/status/route.ts | 11 + src/app/api/usage/summary/route.ts | 15 + src/app/api/workspace/download/route.ts | 9 +- src/app/api/workspace/file/route.ts | 17 +- src/app/api/workspace/preview/file/route.ts | 66 + src/app/api/workspace/preview/route.ts | 74 + src/app/api/workspace/route.ts | 11 +- src/app/api/workspace/upload/route.ts | 13 +- src/app/components/ActionGroup.tsx | 479 ++-- src/app/components/AgentsPanel.tsx | 20 +- src/app/components/BuiltinProvidersEditor.tsx | 1263 ++++++++++ src/app/components/ChatInterface.tsx | 669 ++++-- src/app/components/ChatMessage.tsx | 93 +- src/app/components/ConfigDialog.tsx | 148 +- src/app/components/DocxPreview.tsx | 71 + src/app/components/HealthIndicator.tsx | 253 +- src/app/components/ModelProvidersEditor.tsx | 49 + src/app/components/ProviderProfilesEditor.tsx | 1064 +++++++++ .../RegistryBuiltinProvidersEditor.tsx | 1222 ++++++++++ src/app/components/ResearchDashboard.tsx | 18 +- src/app/components/StreamingMarkdown.tsx | 28 + src/app/components/ThreadList.tsx | 16 +- src/app/components/ToolApprovalInterrupt.tsx | 66 +- src/app/components/ToolCallBox.tsx | 29 +- src/app/components/UsageDetailsDialog.tsx | 382 +++ src/app/components/WorkspaceFileDialog.tsx | 465 +++- src/app/components/WorkspacePanel.tsx | 94 +- src/app/globals.css | 28 + src/app/hooks/useAsyncAgents.ts | 84 +- src/app/hooks/useAutoNotify.ts | 4 +- src/app/hooks/useAvailableModels.ts | 71 +- src/app/hooks/useChat.ts | 804 +++++-- src/app/hooks/useScheduledTasks.ts | 126 +- src/app/hooks/useThreads.ts | 393 +--- src/app/hooks/useTypewriterText.ts | 115 + src/app/hooks/useUsage.test.ts | 65 + src/app/hooks/useUsage.ts | 194 ++ src/app/layout.tsx | 8 +- src/app/page.tsx | 155 +- src/lib/actionGrouping.test.ts | 110 + src/lib/actionGrouping.ts | 110 + src/lib/approvalDecision.test.ts | 45 + src/lib/approvalDecision.ts | 29 + src/lib/autoApprove.ts | 73 +- src/lib/config.ts | 36 - src/lib/conversationApi.test.ts | 23 + src/lib/conversationApi.ts | 286 +++ src/lib/defaultModel.ts | 37 + src/lib/legacyLlmConfig.ts | 227 ++ src/lib/modelCommand.ts | 18 +- src/lib/modelPreference.test.ts | 60 + src/lib/modelPreference.ts | 67 + src/lib/providerProfiles.ts | 225 ++ src/lib/reviewMode.test.ts | 114 + src/lib/reviewMode.ts | 202 ++ src/lib/runRecovery.test.ts | 132 ++ src/lib/runRecovery.ts | 178 ++ src/lib/server/activeDeployment.test.ts | 97 + src/lib/server/activeDeployment.ts | 165 ++ src/lib/server/conversationResponse.test.ts | 65 + src/lib/server/conversationResponse.ts | 61 + src/lib/server/conversationWorkspace.ts | 104 + src/lib/server/evoscientistAdminProxy.ts | 170 ++ src/lib/server/officePreview.test.ts | 132 ++ src/lib/server/officePreview.ts | 405 ++++ src/lib/server/scopeDrain.test.ts | 133 ++ src/lib/server/scopeDrain.ts | 146 ++ src/lib/server/scopeRegistryClient.ts | 208 ++ src/lib/server/usageConfig.test.ts | 50 + src/lib/server/usageConfig.ts | 135 ++ src/lib/server/usageStore.test.ts | 267 +++ src/lib/server/usageStore.ts | 760 ++++++ src/lib/server/workspace.ts | 37 +- src/lib/streamMessages.test.ts | 72 + src/lib/streamMessages.ts | 122 + src/lib/subAgentActivity.ts | 2 +- src/lib/toolLabel.test.ts | 44 + src/lib/toolLabel.ts | 62 +- src/lib/typewriter.test.ts | 27 + src/lib/typewriter.ts | 52 + src/lib/uiSettings.ts | 8 +- src/lib/usageTurn.test.ts | 39 + src/lib/usageTurn.ts | 15 + src/lib/usageTypes.test.ts | 57 + src/lib/usageTypes.ts | 275 +++ src/providers/ChatProvider.tsx | 7 +- src/providers/ClientProvider.tsx | 47 - src/proxy.ts | 11 +- vitest.config.ts | 14 + 150 files changed, 20503 insertions(+), 1868 deletions(-) create mode 100644 docs/conversation-workspace-isolation.md create mode 100644 docs/office-preview.md create mode 100644 docs/schemas/fixtures/accepted/confirmed.json create mode 100644 docs/schemas/fixtures/accepted/unknown.json create mode 100644 docs/schemas/fixtures/identity/workspace-posix.json create mode 100644 docs/schemas/fixtures/identity/workspace-root-and-symlink.json create mode 100644 docs/schemas/fixtures/identity/workspace-unicode.json create mode 100644 docs/schemas/fixtures/identity/workspace-windows.json create mode 100644 docs/schemas/fixtures/metadata/async-subagent.json create mode 100644 docs/schemas/fixtures/metadata/memory.json create mode 100644 docs/schemas/fixtures/metadata/new-message.json create mode 100644 docs/schemas/fixtures/metadata/resume.json create mode 100644 docs/schemas/fixtures/projection/conflict.json create mode 100644 docs/schemas/fixtures/projection/conflicting-confirmed.json create mode 100644 docs/schemas/fixtures/projection/idempotency.json create mode 100644 docs/schemas/fixtures/projection/unknown.json create mode 100644 docs/schemas/fixtures/providers/compatibility.json create mode 100644 docs/schemas/fixtures/query/bigint.json create mode 100644 docs/schemas/fixtures/rejected/unsafe-token-sum.json create mode 100644 docs/schemas/fixtures/spool/ack-crash.json create mode 100644 docs/schemas/implementation-evidence.md create mode 100644 docs/schemas/provider-reconciliation-capabilities.md create mode 100644 docs/schemas/provider-usage-compatibility.md create mode 100644 docs/schemas/usage-api-v1.md create mode 100644 docs/schemas/usage-event-v1.schema.json create mode 100644 docs/schemas/usage-spool-v1.md create mode 100644 docs/token统计方案.md create mode 100644 docs/webui-approval-modes.md create mode 100644 scripts/verify-standalone.mjs create mode 100644 scripts/verify-workspace-isolation.mjs create mode 100644 src/app/api/config/route.ts create mode 100644 src/app/api/conversations/[threadId]/async-tasks/[taskId]/route.ts create mode 100644 src/app/api/conversations/[threadId]/async-tasks/route.ts create mode 100644 src/app/api/conversations/[threadId]/file-state/route.ts create mode 100644 src/app/api/conversations/[threadId]/route.ts create mode 100644 src/app/api/conversations/[threadId]/runs/[runId]/cancel/route.ts create mode 100644 src/app/api/conversations/[threadId]/runs/[runId]/route.ts create mode 100644 src/app/api/conversations/[threadId]/runs/[runId]/stream/route.ts create mode 100644 src/app/api/conversations/[threadId]/runs/route.test.ts create mode 100644 src/app/api/conversations/[threadId]/runs/route.ts create mode 100644 src/app/api/conversations/route.ts create mode 100644 src/app/api/default-model/route.ts create mode 100644 src/app/api/deployment/assistant/route.ts delete mode 100644 src/app/api/evosci-config/route.ts create mode 100644 src/app/api/provider-actions/route.ts create mode 100644 src/app/api/provider-profiles/route.ts create mode 100644 src/app/api/scheduled-tasks/route.ts create mode 100644 src/app/api/usage/calls/route.ts create mode 100644 src/app/api/usage/capabilities/route.ts create mode 100644 src/app/api/usage/events/route.ts create mode 100644 src/app/api/usage/routes.test.ts create mode 100644 src/app/api/usage/sources/heartbeat/route.ts create mode 100644 src/app/api/usage/status/route.ts create mode 100644 src/app/api/usage/summary/route.ts create mode 100644 src/app/api/workspace/preview/file/route.ts create mode 100644 src/app/api/workspace/preview/route.ts create mode 100644 src/app/components/BuiltinProvidersEditor.tsx create mode 100644 src/app/components/DocxPreview.tsx create mode 100644 src/app/components/ModelProvidersEditor.tsx create mode 100644 src/app/components/ProviderProfilesEditor.tsx create mode 100644 src/app/components/RegistryBuiltinProvidersEditor.tsx create mode 100644 src/app/components/StreamingMarkdown.tsx create mode 100644 src/app/components/UsageDetailsDialog.tsx create mode 100644 src/app/hooks/useTypewriterText.ts create mode 100644 src/app/hooks/useUsage.test.ts create mode 100644 src/app/hooks/useUsage.ts create mode 100644 src/lib/actionGrouping.test.ts create mode 100644 src/lib/actionGrouping.ts create mode 100644 src/lib/approvalDecision.test.ts create mode 100644 src/lib/approvalDecision.ts delete mode 100644 src/lib/config.ts create mode 100644 src/lib/conversationApi.test.ts create mode 100644 src/lib/conversationApi.ts create mode 100644 src/lib/defaultModel.ts create mode 100644 src/lib/legacyLlmConfig.ts create mode 100644 src/lib/modelPreference.test.ts create mode 100644 src/lib/modelPreference.ts create mode 100644 src/lib/providerProfiles.ts create mode 100644 src/lib/reviewMode.test.ts create mode 100644 src/lib/reviewMode.ts create mode 100644 src/lib/runRecovery.test.ts create mode 100644 src/lib/runRecovery.ts create mode 100644 src/lib/server/activeDeployment.test.ts create mode 100644 src/lib/server/activeDeployment.ts create mode 100644 src/lib/server/conversationResponse.test.ts create mode 100644 src/lib/server/conversationResponse.ts create mode 100644 src/lib/server/conversationWorkspace.ts create mode 100644 src/lib/server/evoscientistAdminProxy.ts create mode 100644 src/lib/server/officePreview.test.ts create mode 100644 src/lib/server/officePreview.ts create mode 100644 src/lib/server/scopeDrain.test.ts create mode 100644 src/lib/server/scopeDrain.ts create mode 100644 src/lib/server/scopeRegistryClient.ts create mode 100644 src/lib/server/usageConfig.test.ts create mode 100644 src/lib/server/usageConfig.ts create mode 100644 src/lib/server/usageStore.test.ts create mode 100644 src/lib/server/usageStore.ts create mode 100644 src/lib/streamMessages.test.ts create mode 100644 src/lib/streamMessages.ts create mode 100644 src/lib/toolLabel.test.ts create mode 100644 src/lib/typewriter.test.ts create mode 100644 src/lib/typewriter.ts create mode 100644 src/lib/usageTurn.test.ts create mode 100644 src/lib/usageTurn.ts create mode 100644 src/lib/usageTypes.test.ts create mode 100644 src/lib/usageTypes.ts delete mode 100644 src/providers/ClientProvider.tsx create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example index defd599..af95b8e 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,54 @@ WEBUI_AUTH_SECRET=replace-with-at-least-32-random-characters # Optional. Defaults to 12 hours. 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 +# /.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 /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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5891d9c..b9970ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,15 +16,20 @@ permissions: jobs: ci: - name: Format, lint & build - runs-on: ubuntu-latest + name: ${{ matrix.os }} / Node ${{ matrix.node }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node: [20] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: ${{ matrix.node }} cache: npm # `npm ci` does a clean, reproducible install from package-lock.json @@ -38,5 +43,14 @@ jobs: - name: Lint run: npm run lint + - name: Test + run: npm test + - name: 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24b0e2e..f015d19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ We appreciate your interest and the time you spend helping improve the EvoScient ## The zero-touch-backend principle > [!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 @@ -73,8 +73,7 @@ EvoScientist-WebUI/ scripts/ # assemble-standalone.mjs (packs dist/, strips *.map) src/ app/ - api/ # thin same-origin server routes (browser → local disk) - evosci-config/ # detect backend port from EvoScientist config + api/ # same-origin BFF routes (browser → active deployment) skills/ # list / install / uninstall + remote EvoSkills catalog memory/ # global memory CRUD + observation graph + executions workspace/ # browse / read / edit / upload / download-zip files @@ -86,16 +85,16 @@ EvoScientist-WebUI/ lib/ # client helpers (asyncAgents, cronUtils, modelCommand, # observationGraph, fileLink, summarization, …) server/ # server-only fs helpers (workspace / memory / skills) w/ path guards - providers/ # ClientProvider (SDK) · ChatProvider · ThemeProvider + providers/ # ChatProvider · ThemeProvider ``` ## Architecture & connection contract Three independent layers: -1. **Frontend** (Next.js standalone, port `4716`) — browser connects directly to the LangGraph backend; pure client-side chat + streaming. -2. **Next `/api/` routes** (server tool layer, same-origin) — the reason distribution is `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. +1. **Frontend** (Next.js standalone, port `4716`) — browser uses same-origin `fetch` for chat, streaming, workspace and model operations. +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 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. diff --git a/README.md b/README.md index ca24966..ffe23b3 100644 --- a/README.md +++ b/README.md @@ -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. +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 `. +

🔝Back to top

## 🔑 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. +- **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. - _(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] > If the backend changes ports, the health light detects the dead connection and offers a one-click **Reconnect** to the newly detected port. diff --git a/docs/conversation-workspace-isolation.md b/docs/conversation-workspace-isolation.md new file mode 100644 index 0000000..8e373a5 --- /dev/null +++ b/docs/conversation-workspace-isolation.md @@ -0,0 +1,1331 @@ +# WebUI 会话级工作目录隔离修改方案 + +> 版本:1.4 | 日期:2026-07-18 | 所属项目:EvoScientist-WebUI / EvoScientist +> 状态:实施中。WebUI BFF、会话 scope 解析和 cutover owner 对账已实现;切换 `required` +> 前仍须在目标部署执行全量 cutover 并验证运行环境。 + +## 1. 决策摘要 + +当前 WebUI 和 EvoScientist 后端共同使用部署级固定工作目录。文件上传、文件树、 +Agent 文件工具和后台命令都可能在同一个物理目录中读写,因此不同对话之间没有 +文件边界。 + +本方案将工作目录改为按主对话隔离: + +```text +一个主 LangGraph thread + -> 一个不可变 workspace_scope_id + -> 一个独立、可写的物理工作目录 +``` + +推荐目录结构: + +```text +/ + .evoscientist/ + conversations/ + / + files/ # Agent 看到的虚拟根目录“/” + runtime/ # 后台任务日志等内部运行数据 + tombstone.json # 仅在删除或回收过程中出现 + trash/ # 已删除对话的延迟清理目录 +``` + +核心行为: + +- 对话 A 的上传文件和 Agent 生成文件只进入 A 的 `files/`。 +- 对话 A 的 WebUI 文件接口和 Agent 工具不能列出、读取或修改对话 B 的文件。 +- 同一对话刷新、重新打开、审批恢复和断线恢复后继续使用原目录。 +- 同步子代理、异步子代理、Memory Worker、后台进程和定时任务继承主对话的 + `workspace_scope_id`,不能按自己的内部 thread ID 新建目录。 +- `/skills/` 和 `/memories/` 保持显式共享,不混入私有工作目录。 +- 浏览器不传 scope 或物理目录;scope、运行配置和物理路径均由服务端计算。 +- `required` 模式的 shell 和后台命令在容器化执行器中运行,只挂载当前 scope 的 + `files/`;现有宿主机 `LocalShellBackend` 不构成严格隔离边界。 + +本方案解决的是“对话之间的文件隔离”。它不是多用户 ACL,也不会自动隔离共享 +Memory、Skills、模型上下文或拥有独立文件系统权限的 MCP 工具。 + +## 2. 目标和非目标 + +### 2.1 目标 + +1. 上传文件、Agent 生成文件和后台任务输出按主对话隔离。 +2. WebUI 文件树、预览、编辑、删除、下载和容量统计只操作当前对话目录。 +3. 所有 run 和 resume run 使用同一个稳定 scope。 +4. 主代理及其子代理共享当前对话文件,但不能访问其他对话文件。 +5. 目录计算、路径校验、进程归属和删除操作可自动化测试。 +6. 支持现有公共 workspace 平滑迁移,不把旧文件复制到所有对话。 +7. 在并发执行多个对话时不依赖可变全局工作目录。 + +### 2.2 非目标 + +第一阶段不处理: + +- 新增用户、组织、角色或多租户权限系统。 +- 把 `threadId` 当作用户认证凭据。 +- 自动判断公共 workspace 中每个历史文件属于哪个旧对话。 +- 默认复制公共项目源码到每个新对话。 +- 隔离 `/skills/`、`/memories/` 中的共享内容。 +- 为允许任意真实文件系统访问的 `dangerous_mode` 提供隔离承诺。 +- 在未审计的 MCP 文件工具外部强行建立可靠的文件边界。 + +## 3. 当前实现与问题 + +### 3.1 WebUI 使用部署级目录 + +`src/lib/server/workspace.ts` 的 `getWorkspaceDir()` 通过 sidecar、环境变量或默认值 +解析一个部署级工作目录。它没有接收 thread 或 conversation scope。 + +现有 Workspace API 均直接使用这个目录: + +```text +src/app/api/workspace/route.ts +src/app/api/workspace/file/route.ts +src/app/api/workspace/upload/route.ts +src/app/api/workspace/download/route.ts +``` + +例如上传接口把文件直接写入 `getWorkspaceDir()` 返回的根目录,文件树接口也会列出 +同一个根目录。当前列表响应还返回真实 `dir`,隔离后必须删除这个字段。 + +### 3.2 上传发生时可能还没有 thread + +`ChatInterface` 允许 New Chat 在发送第一条消息前上传文件,但 `useChat` 目前只在 +创建第一个 run 时调用 `client.threads.create()`。因此单纯要求上传接口携带 +`threadId` 会破坏 New Chat 上传流程。 + +本方案通过“首次上传或首次发送前预创建草稿 thread”解决,不使用临时上传目录和 +后续搬迁。 + +### 3.3 Agent backend 在启动时固定根目录 + +`../EvoScientist/EvoScientist/EvoScientist.py` 的 `_get_default_backend()` 当前在 graph +构建时创建: + +```python +CustomSandboxBackend(root_dir=WORKSPACE_ROOT) +``` + +graph 会跨请求复用。不能在每个 run 开始前修改全局 `WORKSPACE_ROOT` 或调用全局 +`set_active_workspace()` 切换目录,否则对话 A、B 并发时会发生目录串用。 + +### 3.4 部分执行面绕过 Agent 文件 backend + +`../EvoScientist/EvoScientist/middleware/background.py` 当前通过全局 +`paths.resolve_virtual_path("/")` 计算后台命令 cwd。后台进程查询和停止也没有完整的 +会话所有权校验。 + +异步子代理会创建独立的内部 LangGraph thread。若直接使用当前运行的 `thread_id` +作为工作目录,它会与父对话分离;正确行为是继承父对话的 `workspace_scope_id`。 + +定时任务同样可能在新的 scheduler thread 中执行,必须保存创建任务时的 scope。 + +## 4. 隔离边界和不变量 + +实现必须持续满足以下不变量: + +1. 物理路径只能由可信部署根目录和服务端校验后的 scope 计算。 +2. 主对话的 `workspace_scope_id` 创建后不可修改。 +3. UI 选择其他对话只会切换当前 scope,不会合并目录。 +4. 子代理、Memory Worker、Scheduler 的内部 thread ID 不改变文件 scope。 +5. 对话 B 访问对话 A 的文件时返回 `404`,不暴露文件是否存在。 +6. `..`、绝对路径、控制字符和符号链接均不能逃出当前 scope。 +7. 缺少、冲突或由浏览器伪造的 scope 不能进入严格模式 run,也不能回落到公共根目录。 +8. WebUI API 不返回部署物理路径,且文件系统根与 LangGraph API 必须属于同一 deployment。 +9. 删除、下载、容量统计、Memory Worker、后台进程和定时任务使用与文件读写相同的 scope。 +10. 任何请求路径不得调用全局 `paths.resolve_virtual_path()` 或可变的 `set_active_workspace()` + 来解析当前对话文件。 +11. 开启严格隔离时禁止 `dangerous_mode`。 + +这里的“其他对话不能查看”包含两个层次: + +- Agent 边界:对话 A 中的模型和工具不能看到 B 的文件。 +- WebUI 边界:当前打开 A 时,Workspace 面板和文件 API 只能访问 A。 + +当前项目是单用户可信部署。用户主动切换到对话 B 后仍可查看 B 自己的文件,这是 +正常行为。若未来支持多用户,还必须增加 `user_id -> thread_id` 所有权校验,不能仅 +依赖 thread ID。 + +## 5. 标识和数据模型 + +### 5.1 使用独立的 workspace_scope_id + +新增运行字段: + +```text +workspace_scope_id +``` + +每个主对话都在后端 Scope Registry 中有一条不可变映射: + +```text +deployment_id + primary_thread_id -> workspace_scope_id +``` + +新对话由服务端生成 UUIDv7 scope。实现可以复用该 UUID 作为新建 thread ID,但不能把 +两者相等视为安全条件;旧 thread 的 scope 由迁移流程生成,可能与原 thread ID 不同。 + +仍使用独立字段而不是到处直接读取 `thread_id`,原因是: + +- 异步子代理拥有自己的 thread ID,但必须继承父目录。 +- Scheduler thread 不等于创建定时任务的对话。 +- 未来复制或分支对话时可以明确选择新建、复制或共享策略。 +- 日志和测试可以区分“执行 thread”和“文件所有者”。 + +scope 仅由服务端生成,始终为标准 UUID。目录名不使用标题、模型输出、浏览器输入或 +原始 thread ID;客户端传入的 thread ID 只用于查询 Registry 映射。 + +### 5.2 Thread metadata + +主 thread metadata 增加: + +```json +{ + "workspace_schema_version": 1, + "workspace_scope_id": "018f...", + "workspace_status": "draft", + "assistant_id": "..." +} +``` + +状态定义: + +| 状态 | 含义 | +| ---------- | ------------------------------------ | +| `draft` | 已为上传预创建,但尚未发送第一条消息 | +| `active` | 已发送消息,正常显示在历史列表 | +| `deleting` | 正在删除,拒绝新的文件和 run 操作 | + +Thread metadata 是对 LangGraph 的可查询镜像;后端 Scope Registry 才是 scope 归属、 +状态和派生任务所有权的权威来源。两者必须保持同一 `workspace_scope_id` 和状态;不 +一致时严格模式失败关闭并由修复任务处理。 + +### 5.3 Scope Registry + +v1 将 Registry 固定实现为 EvoScientist 进程可访问、Agent 永不挂载的 SQLite 数据库: + +```text +/.evoscientist/control/scope-registry.sqlite3 +``` + +目录权限为 `0700`、数据库文件为 `0600`。复用项目已有的 `aiosqlite` 访问方式,启动时 +设置 `foreign_keys=ON`、WAL journal 和 30 秒 busy timeout。v1 的 `required` 模式仅支持 +单主机、单个 EvoScientist deployment;部署清单必须显式声明 +`EVOSCIENTIST_SCOPE_REGISTRY_TOPOLOGY=single-host`,否则拒绝启动 `required`。多副本或 +跨主机部署必须等待后续 PostgreSQL Registry adapter,不能把 SQLite 放在网络共享盘上。 + +WebUI 通过服务端凭据调用内部 API,浏览器不能直接读写 Registry。数据库迁移使用 +`PRAGMA user_version`,由 `scope_registry.py` 在后端启动时顺序执行;迁移失败时后端不 +提供 Workspace 或 run 服务。 + +```text +scopes + deployment_id, scope_id, primary_thread_id, state, revision, + created_at, updated_at, deleted_at + PK (deployment_id, scope_id) + UNIQUE (deployment_id, primary_thread_id) + +scope_owners + deployment_id, owner_id, scope_id, owner_type, resource_id, + parent_owner_id, state, created_at, updated_at, terminal_at + PK (deployment_id, owner_id) + FK (deployment_id, scope_id) -> scopes + UNIQUE (deployment_id, owner_type, resource_id) WHERE resource_id IS NOT NULL + +scope_run_requests + deployment_id, scope_id, run_request_id, turn_id, interrupt_key, + request_hash, run_owner_id, run_id, state, created_at, updated_at + PK (deployment_id, scope_id, run_request_id) + UNIQUE (deployment_id, scope_id, interrupt_key) WHERE interrupt_key IS NOT NULL + +scope_operations + deployment_id, operation_id, scope_id, kind, expected_revision, state, + external_resource_id, result_sha256, last_error_code, created_at, updated_at + PK (deployment_id, operation_id) + INDEX (deployment_id, scope_id, state) + scope_id 仅 deployment 级 cutover operation 可为 NULL + +deployment_locks + deployment_id, lock_name, operation_id, expires_at, created_at + PK (deployment_id, lock_name) +``` + +`owner_type` 至少覆盖 `primary_thread`、`async_thread`、`memory_worker`、`cron`、 +`background_process` 和 `run`。所有派生任务在创建前先登记 owner;删除时通过该表 +查询、取消并等待全部 owner 终态。owner 的状态只能为 `reserved`、`active`、`draining`、 +`terminal`、`failed` 或 `quarantined`,且 `terminal`、`quarantined` 不能回到 active。Registry +scope 的状态迁移为: + +```text +provisioning -> draft -> active -> deleting -> deleted +``` + +创建、迁移、进入 `deleting` 和登记 owner 均使用 `BEGIN IMMEDIATE` 事务。状态转换使用 +`UPDATE ... WHERE revision = :expected_revision AND state IN (...)`,影响行数不是 1 时返回 +冲突;成功时 revision 加 1。owner 先以 UUID `owner_id` 预登记,再写入外部 `resource_id` +(thread、run、cron 或 container ID);同一外部资源插入到其他 scope 时由唯一索引拒绝。 + +LangGraph、文件系统和 Registry 没有跨系统事务。`provision`、run 创建、cron 创建和删除 +都以 `scope_operations` 的 UUID `operation_id` 记录,按“预留 -> 外部副作用 -> 提交/补偿” +执行。operation 状态只能按 `reserved -> applying -> completed` 或 +`reserved/applying/failed -> compensating -> compensated` 转换;恢复任务只处理非终态 +operation,并依据其 kind、external resource 和 last error 做幂等补偿。`turn_id` 表示一个 +逻辑用户回合;每次 `runs.create` 使用新的 `run_request_id`。同一 `run_request_id` 的 +`request_hash` 必须一致才返回既有 run,不一致返回 `409 idempotency_key_conflict`;同一 +`turn_id` 的不同请求 ID 是合法的审批或追问恢复。带 `interrupt_key` 的 resume 对同一 scope +只能预留一次,重复决定返回 `409 interrupt_already_resolved`。后台修复任务可依据 thread +metadata、cron metadata 和运行记录补全已知 owner,但不能把未知 owner 当作可安全删除。 + +Registry 由 EvoScientist 提供仅服务端可调用的内部接口,使用独立服务凭据: + +```text +POST /internal/workspace-scopes/provision +GET /internal/workspace-scopes/by-thread/ +POST /internal/workspace-scopes//owners +PATCH /internal/workspace-scopes//owners/ +GET /internal/workspace-scopes//owners/by-resource/ +POST /internal/workspace-scopes//runs/reserve +PATCH /internal/workspace-scopes//runs/ +PATCH /internal/workspace-scopes/ +``` + +每个接口只接受 loopback/private 网络上的 `Authorization: Bearer +EVOSCIENTIST_BACKEND_SERVICE_TOKEN`;`deployment_id` 从后端自身配置读取,不能由请求体、 +header 或 WebUI 选择。所有写接口记录 `operation_id` 并校验预期 revision。 + +创建新对话时,BFF 先建立 `provisioning` Registry reservation,再创建携带 scope metadata +的 LangGraph thread 和 `files/` 目录,最后原子提交为 `draft`。任一步失败都删除已创建 +资源或保留可重试的 provisioning record,绝不把半成品视为 active。 + +### 5.4 Run config 和 metadata + +新 run、resume run 和异步子代理 run 都显式携带: + +```json +{ + "config": { + "configurable": { + "workspace_scope_id": "018f...", + "workspace_scope_owner_id": "primary-thread-or-internal-owner-id", + "workspace_scope_revision": 4 + } + }, + "metadata": { + "workspace_scope_id": "018f...", + "workspace_scope_owner_id": "primary-thread-or-internal-owner-id" + } +} +``` + +`configurable` 用于运行时 backend 解析,metadata 用于异步派生、恢复、审计和故障 +排查。两者都存在时必须一致,否则拒绝运行。`workspace_scope_owner_id` 是 Registry +创建的内部 owner ID:主图使用 primary thread owner,异步线程和 Memory Worker 在创建 +内部 thread 前登记,cron 在创建前使用预生成 owner ID。浏览器不能提供该字段。 + +浏览器不能自行决定该字段。严格模式中的 thread 创建、主 run 和 resume run 都经过 +WebUI 服务端对话服务;服务端从已持久化的主 thread 记录读取 scope,再注入 run +config。浏览器只传白名单业务字段(消息、审批结果、上传文件、`turn_id`、 +`run_request_id`、可选 `interrupt_key`、审核模式和模型选择),不传 +`workspace_scope_id`、owner、metadata 或运行 config。 + +### 5.5 Scope 权威性 + +主图在后端必须再次校验,而不能只相信 WebUI: + +```text +主图:Registry(scope_id).primary_thread_id == runtime 的实际 thread_id +派生图:Registry 中存在 active owner,且 config、metadata、owner 与 scope 一致 +``` + +主图的实际 thread ID 由 LangGraph runtime 提供,不从浏览器请求体读取。异步子代理、 +Memory Worker 和 Scheduler 可拥有不同内部 thread ID,但其 scope 只能由已登记的 +父 owner 传播。严格模式中,缺少 Registry 记录、scope 正在 `deleting` 或出现不一致 +时一律失败。 + +`require_scoped_runtime()` 在每次 graph 运行和每次直接文件工具调用时读取 Registry: +校验 deployment、scope、owner、runtime thread 与状态。运行配置中的 revision 只用于 +诊断;Registry 当前状态才是最终判断,因而删除开始后持有旧 revision 的任务不能继续 +访问目录。内存缓存只能保存已构造的 backend 以减少重复初始化,不能作为授权结果;每次 +实际文件或命令操作仍须先通过 Registry 校验。 + +## 6. 目录解析设计 + +### 6.1 Deployment 绑定和服务端对话服务 + +浏览器不得选择 LangGraph deployment URL。WebUI 启动后会清理旧版 localStorage 配置; +所有对话、模型、健康检查和 Workspace 请求都从服务端 `ActiveDeployment` 解析同一 +sidecar/服务端环境配置,严格隔离不会混用两个 deployment。 + +WebUI 服务端新增不可由浏览器覆盖的 `ActiveDeployment` 解析器: + +```ts +interface ActiveDeployment { + deploymentId: string; + langgraphApiUrl: string; + threadClient: ServerOnlyLangGraphClient; + scopeRegistry: ServerOnlyScopeRegistryClient; + workspaceRoot: string; +} +``` + +部署启动器写入或更新 sidecar 时,同时记录 deployment ID、后端 API 地址和 workspace +根目录。WebUI 只信任该 sidecar 或显式服务端环境变量,并验证后端健康状态与 +deployment ID 一致;浏览器永不接收或提交 deployment URL、上游 API key 或 assistant +选择,因而不能决定 Workspace API、thread 创建、run 创建或删除所使用的 deployment。 + +严格模式采用固定拓扑,不能让浏览器拥有 LangGraph 凭据: + +```text +Browser + -> WebUI BFF(同源认证) + -> LangGraph / EvoScientist(私网或 loopback + 服务端凭据) +``` + +LangGraph API 必须绑定私网或 loopback,并要求仅存于 WebUI 与 EvoScientist 服务端环境 +或同机 workspace 的 `.evoscientist/control/scope-service-token`(`0600`)中的服务凭据。 +后者仅由 WebUI BFF 在本机读取,绝不进入 sidecar、响应、浏览器 localStorage 或日志。 +`NEXT_PUBLIC_LANGSMITH_API_KEY`、浏览器 localStorage 中的 API key 和浏览器直连 `Client` +在 `required` 模式均禁止使用。sidecar 原子写入且权限为 `0600`,不包含服务凭据。 + +本方案仍是单用户文件隔离。`required` 模式下,WebUI 绑定非 loopback 地址时必须启用 +登录认证;未认证的远程 BFF 启动失败。loopback 单用户部署可沿用本机可信前提。多用户 +场景还需要在 BFF 中校验 `user_id -> primary_thread_id` 所有权,不属于本期范围。 + +新增服务端对话接口: + +```text +GET /api/conversations # 历史列表 +POST /api/conversations # 创建 draft thread +GET /api/conversations/ # 已脱敏的 thread 状态与历史 +PATCH /api/conversations/ # title、pinned、model_override +PUT /api/conversations//file-state # 已上传文件的会话附件状态 +GET /api/conversations//export # 下载已脱敏的完整对话 JSON +DELETE /api/conversations/ # 删除 thread 和 scope +POST /api/conversations//runs # 创建主 run 或 resume run +GET /api/conversations//runs # 列表、状态和恢复发现 +GET /api/conversations//runs/ # 单个主 run 状态 +POST /api/conversations//runs//cancel +GET /api/conversations//runs//stream + # 服务端转发 SSE + +GET /api/conversations//async-tasks +GET /api/conversations//async-tasks/ +POST /api/conversations//async-tasks//runs + +GET /api/conversations//schedules +POST /api/conversations//schedules +PATCH /api/conversations//schedules/ +DELETE /api/conversations//schedules/ +POST /api/conversations//schedules//run + +GET /api/deployment/assistant # 当前部署的只读 assistant 描述 +``` + +`ensureThread()`、`sendMessage()`、`resumeInterrupt()`、线程列表、重命名、置顶、模型切换、 +run 恢复、异步代理面板和定时任务面板都改为调用上述服务端接口。严格模式删除 +`ClientProvider`、浏览器 `Client`、`NEXT_PUBLIC_LANGSMITH_API_KEY` 和 localStorage API key; +浏览器只使用同源 `fetch` 的 `ConversationApi`。服务端创建 thread 时生成 UUID、写入不可变 +scope metadata,并以同一个可信 `ActiveDeployment` 创建 thread。 + +`PATCH /conversations/` 只接受 `{ title?, pinned?, model_override? }`。服务端读取 +现有 metadata 后合并允许字段,永不接受 scope、graph、assistant 或 deployment 字段。 +`model_override` 先经服务端模型 allowlist 校验再持久化;后续 run 使用该值,从而在刷新和 +重新打开后保持用户选择。`PUT /file-state` 只接受文件 descriptor 的允许字段,服务端忽略 +客户端传入的物理路径、大小和 MIME 结论,并逐个验证虚拟路径存在于当前 scope,之后才更新 +thread state,不能把该端点作为任意状态写入接口。`GET /export` 只导出当前主 thread 的 +脱敏 history/state,且不能返回 scope、服务凭据、物理目录或派生 thread 的未授权状态。 + +`POST /runs` 的请求体只允许以下字段:`turn_id`、`run_request_id`、可选 +`interrupt_key`、`input`、`command`、`review_mode`、`model_override`。它拒绝 `thread_id`、 +`workspace_scope_id`、`metadata`、`config`、 +`assistant_id` 和 deployment URL。若携带 `model_override`,它是持久化模型选择:服务端先 +校验并写入该主 thread,再用同一值创建 run;不提供时读取已保存选择。服务端执行以下步骤: + +1. 从 Registry 读取 active scope,并验证主 thread 归属。 +2. 验证 `turn_id` 与 `run_request_id` 为 UUID,按 `scope_id + run_request_id` 查找已有 + run,保证单次请求重试幂等;同一 `turn_id` 的 resume 使用新的 `run_request_id`。 +3. 对 `review_mode` 和 `model_override` 按服务端允许列表校验。 +4. 合并服务端生成的 thread、scope、模型与审核配置,写入 run config 和 metadata。 +5. 登记 run owner 后创建 run;失败时回滚未启动的 owner 记录。 +6. 返回固定 `{ threadId, runId, status, turnId, runRequestId }`,浏览器通过 BFF 查询或订阅。 + +SSE、thread 查询、run 查询、取消和历史列表也必须走 BFF,不能在严格模式回退到浏览器 +SDK。BFF 转发时只转发已属于当前 `ActiveDeployment` 和当前 scope 的记录。 + +异步接口先从主 thread 的 `async_tasks` 找到 `taskId`,再验证该 child thread/run 在 +Registry 中是当前 scope 的 active `async_thread`/`run` owner;浏览器提交的 child thread ID、 +agent 名称和 scope 一律忽略。追问接口仅接收 `{ turn_id, input }`,继承该 child owner 的 +scope config,且 child 已终态、deleting 或不属于当前主对话时返回 `409`/`404`。 + +定时任务页面改为“当前对话的定时任务”,没有 active thread 时禁用。所有 schedule API 以 +主 thread 查询 scope;创建时先登记 cron owner,并在 cron config 和 metadata 写入 scope、 +owner、revision。更新采用“创建新 cron 后删除旧 cron”的 saga,两个 cron 均由当前 scope +校验;立即执行同样从已登记 cron owner 派生 run owner,绝不创建匿名 scheduler thread。 +列表、读取、更新、删除和立即执行均按 Registry owner 查询,不能调用全局 `crons.search()`。 +创建/更新 body 仅允许 `{ name, prompt, schedule, idempotency_key }`,立即执行仅允许 +`{ turn_id }`;服务端验证 cron 表达式、长度和幂等键,所有其他 cron config/metadata 均由 +服务端生成。 + +审核不在新 run 创建时不可逆地决定。`review_mode` 只定义当前 UI 的默认策略;图产生 +interrupt 后,BFF 返回已校验的 `interrupt_id` 和 action requests,浏览器在每次 +`command.resume` 中提交该 interrupt 的逐项 `approve`、`reject` 或编辑后批准决定。每次 +resume 复用逻辑 `turn_id`,但生成新的 `run_request_id`;同一网络重试复用该请求 ID。BFF +以 `interrupt_key` 拒绝同一 interrupt 的第二个决定,因此用户可在执行过程中改变批准选择, +而刷新、自动批准或多标签竞态不会创建第二个恢复 run。 + +#### 6.1.1 客户端状态和 SSE 迁移 + +`useChat` 必须移除 `useStream`、`UseStreamThread` 和 SDK client。它通过 +`GET /api/conversations/` 获取 `ThreadSnapshot`(messages、todos、interrupts、 +pending task 和允许的 metadata),通过 BFF stream endpoint 接收并解析 SSE;断线后携带 +`Last-Event-ID` 重连,无法续接时重新读取 `ThreadSnapshot`。BFF 只代理已校验主 run 的 +事件,设置 `Cache-Control: no-store`,不把上游 URL 或凭据下发给浏览器。`ChatProvider` 和 +`ConversationApi` 使用本地 TypeScript DTO,不再以 SDK runtime 类型作为客户端状态容器。 + +`GET /api/deployment/assistant` 由服务端从 `ActiveDeployment` 解析当前 assistant,返回仅供 +展示的 `{ assistantId, graphId, name }`;`POST /runs` 始终由服务端选择该 assistant。前端不再 +调用 `client.assistants.get/search`,也不把 deployment URL/API key 写入 `ConfigDialog` 或 +localStorage。严格模式的 ConfigDialog 只展示服务端连接状态和允许的模型,不提供后端 URL、 +assistant ID 或 API key 编辑。 + +严格模式增加静态门禁:脚本扫描 `src/` 中所有非 BFF 模块,必须不存在 `new Client(`、 +`useClient()`、`useStream`、浏览器 deployment URL、公开 API key 或 LangGraph SDK 直连。 +BFF route 和 `src/lib/server/**` 可以使用服务端 SDK,但必须导入 `server-only`;共享类型只能 +使用 `import type`。任何新豁免必须附带安全测试和本方案更新。 + +### 6.2 WebUI 服务端目录解析器 + +保留 `getWorkspaceDir()` 作为部署根目录解析器,新增: + +```ts +interface ConversationWorkspace { + deploymentRoot: string; + scopeId: string; + filesDir: string; + runtimeDir: string; +} + +async function resolveConversationWorkspace( + request: NextRequest, + deployment: ActiveDeployment, + options: { create: boolean } +): Promise; +``` + +解析步骤: + +1. 从请求读取 `threadId`,按不透明 ID 格式和长度校验,绝不用于拼接路径。 +2. 通过传入的服务端 `ActiveDeployment.threadClient` 查询主 thread。 +3. 校验 thread 的 assistant/graph 和 deployment ID 都属于当前 deployment。 +4. 向 Scope Registry 查询 `primary_thread_id -> active scope` 映射,并比对 metadata 镜像。 +5. 使用固定根目录拼接 `.evoscientist/conversations//files`。 +6. 对根目录执行 `realpath` 和包含关系检查。 +7. 仅在 provision 操作中创建目录;普通读取和写入不隐式创建陌生 scope。 + +所有 API 使用这个单一解析器,不能各自拼接路径,也不能接收浏览器提供的 deployment +URL 或物理路径。 + +### 6.3 Agent 运行时解析器 + +EvoScientist 新增独立模块,例如: + +```text +../EvoScientist/EvoScientist/workspace_scope.py +``` + +建议接口: + +```python +def require_workspace_scope_id(runtime: ToolRuntime) -> str: ... +def conversation_workspace_dir(scope_id: str) -> Path: ... +def create_workspace_backend(runtime: ToolRuntime) -> BackendProtocol: ... +def require_scoped_runtime(runtime: ToolRuntime, *, kind: str) -> ScopeContext: ... +def register_scope_owner(context: ScopeContext, owner: ScopeOwner) -> None: ... +def drain_scope(scope_id: str) -> DrainResult: ... +``` + +`create_workspace_backend()` 根据 `runtime.config.configurable.workspace_scope_id` 返回 +当前请求的 scoped backend handle;handle 在 Agent 事件循环中不得执行 Registry、路径或 +文件系统 I/O。实际的 Registry 校验、`realpath` 检查和 `CompositeBackend` 构造在文件操作 +的工作线程中完成: + +```text +default -> 当前对话 files/,可读写 +/skills/ -> 现有共享 Skills backend +/memories/ -> 现有共享 Memory backend +``` + +不能把 `.evoscientist/conversations` 或部署根目录作为 `/project/` 暴露给 Agent,否则 +Agent 可以重新枚举其他 scope。未来若需要公共项目文件,应提供过滤掉内部目录的 +独立只读 backend,并显式挂载为 `/project/`。 + +`workspace_scope.py` 是唯一允许把 scope 变为物理目录的 EvoScientist 模块。请求处理、 +工具和 worker 不得调用 `paths.resolve_virtual_path()` 解析对话文件,也不得在运行期间 +调用 `set_active_workspace()`;这两个全局 API 仅保留给 CLI 兼容路径。 + +### 6.4 Deepagents 兼容性 + +当前依赖为 `deepagents[quickjs]~=0.6.12`。该版本支持按 `ToolRuntime` 创建 backend, +但对应工厂接口已经标记为将在 0.7 移除。 + +实施要求: + +- 第一阶段保持 `~=0.6.12`,不升级到 0.7。 +- 把 deepagents 适配封装在 `create_workspace_backend()`,业务代码不直接依赖弃用接口。 +- runtime backend factory 必须无阻塞:不得在 Agent 事件循环调用 SQLite、`Path.resolve()`、 + `mkdir()` 或构造会执行这些操作的具体 backend。异步文件方法必须通过执行器完成上述工作。 +- 增加启动测试,确认 `create_deep_agent` 能接收运行时 backend 工厂。 +- 升级 deepagents 前,迁移到届时官方的请求级 storage/backend 机制或自定义 + FilesystemMiddleware。 + +## 7. New Chat 和上传流程 + +### 7.1 统一 ensureThread + +在 `useChat` 暴露或内部共享一个幂等方法: + +```ts +ensureThread(): Promise +``` + +行为: + +1. 已有 `threadId` 时直接返回。 +2. 没有 thread 时调用 `POST /api/conversations`;服务端生成 thread ID、scope 和 draft。 +3. 更新 `threadIdRef`、React state 和路由。 +4. 并发上传和发送共用同一个 Promise,避免创建两个 thread。 + +以下入口都必须先调用它: + +- 第一次上传文件。 +- 发送第一条消息。 +- 在 New Chat 中打开 Workspace 面板并执行写操作。 + +### 7.2 上传时序 + +```text +用户在 New Chat 选择文件 + -> ensureThread() + -> 创建 draft thread + workspace_scope_id + -> POST /api/workspace/upload?threadId= + -> 服务端校验 thread + -> 写入 /files/ + -> 返回虚拟路径 /filename.pdf +``` + +上传结果继续使用虚拟路径,例如 `/paper.pdf`。聊天消息只引用虚拟路径,不包含 +`.evoscientist/conversations/...` 物理前缀。 + +### 7.3 草稿清理 + +- Thread 列表默认过滤 `workspace_status=draft`。 +- 第一条消息创建 run 前把状态改为 `active`。 +- 无消息、无活动 run 且超过 24 小时的 draft 由定期清理任务删除。 +- 清理任务同时删除 thread 和 scope 目录,操作要求幂等。 +- 上传失败后不立即删除 draft,以便用户重试;无内容草稿仍由 TTL 处理。 + +不推荐临时上传区方案。它需要在创建 thread 后移动文件,并额外处理崩溃、重名、 +跨文件系统移动和遗留临时文件,复杂度高于预创建草稿 thread。 + +## 8. Workspace API 修改 + +### 8.1 请求约定 + +所有 Workspace API 必须携带 `threadId`: + +```text +GET /api/workspace?threadId=&path=... +GET /api/workspace?threadId=&recursive=1 +POST /api/workspace/upload?threadId= +GET /api/workspace/file?threadId=&path=... +PUT /api/workspace/file?threadId=&path=... +DELETE /api/workspace/file?threadId=&path=... +GET /api/workspace/download?threadId= +``` + +查询参数适用于图片 `src`、下载链接和新标签页,不把 thread ID 当作秘密。未来多用户 +版本仍必须通过登录态校验 thread 所有权。 + +### 8.2 响应和错误 + +| 场景 | 状态码 | 行为 | +| ------------------------------------ | ------ | --------------------- | +| 缺少或非法 thread ID | `400` | 不读取任何目录 | +| thread 不存在或不属于当前 deployment | `404` | 不透露其他 scope 信息 | +| thread 正在删除 | `409` | 禁止上传和写操作 | +| 文件不存在或试图跨 scope | `404` | 统一不可访问响应 | +| 超过单次上传限制 | `413` | 不留下部分文件 | + +列表响应删除真实 `dir` 字段,只返回虚拟相对路径和可展示的 scope 信息。 + +### 8.3 前端调用方 + +以下组件必须使用当前 thread ID: + +- `ChatInterface.tsx`:上传。 +- `WorkspacePanel.tsx`:目录和类型视图、下载全部。 +- `WorkspaceFileDialog.tsx`:预览、编辑、删除、单文件下载。 +- `ResearchDashboard.tsx`:当前对话文件统计。 + +`workspaceFileUrl()` 的签名调整为: + +```ts +workspaceFileUrl(threadId, path, download?) +``` + +组件在 `threadId` 不存在时不得发起全局 Workspace 请求。 + +## 9. Agent 和派生任务传播 + +### 9.1 主 run 和 resume run + +`useChat.buildRunConfig()` 只构造模型和审核模式配置。服务端 run 接口从 Scope Registry +解析 scope 后写入 `configurable` 和 metadata。普通消息、审批恢复、`ask_user` 恢复、 +自动批准和断线恢复发现的后续 run 都必须保持同一 scope。 + +创建 run 前校验: + +```text +Registry(workspace_scope_id).primary_thread_id == 主 thread_id +``` + +加载历史 thread 时从 thread metadata 恢复 scope,而不是从当前 UI 状态猜测。 + +### 9.2 同步子代理 + +同步子代理与父 run 共用当前 `ToolRuntime` 和 backend 工厂。验收时必须验证它读取和 +写入的是父对话目录,并发子代理写文件时仍受同一根目录限制。 + +### 9.3 异步子代理 + +异步子代理会创建自己的 LangGraph thread,因此它的传播规则是: + +```text +child.thread_id != parent.thread_id +child.workspace_scope_id == parent.workspace_scope_id +``` + +`../EvoScientist/EvoScientist/llm/patches.py` 中创建异步 run 的包装逻辑应无条件复制 +`workspace_scope_id` 到 child config 和 metadata。此传播不能依赖 Token 统计是否 +启用。创建 child thread 前生成并登记 owner UUID;child thread ID 与 owner 关联成功后 +才创建 run,失败时清理 reservation。 + +### 9.4 Memory Worker 和自动技能 Worker + +EvoMemory 生命周期、Memory Worker、Observation Linker 和 AutoSkills 是独立 graph, +不能因为 `/memories/` 是共享路由而保留部署级 workspace。当前这些 graph 在启动时 +把 `WORKSPACE_ROOT` 固化为 backend 根目录;严格模式中必须改为运行时 scope factory。 + +- Memory 生命周期从父 runtime 获取 scope,而不是在 middleware 构造时保存 + `WORKSPACE_ROOT`。 +- Worker launch payload 的 `config.configurable` 和 metadata 都携带 scope。 +- Worker launch 前生成并登记 owner UUID;内部 thread 创建后将 thread ID 关联该 owner, + 再允许 run 启动。 +- Memory Worker 的默认文件 backend 对当前 `files/` 只读;它可以读当前对话文件, + 不能枚举 `.evoscientist/conversations` 或其他 scope。 +- Observation Linker、AutoSkills 和关联的 memory scheduler 继承创建它们的 scope。 +- 若上述 graph 无法在本次改造为运行时 backend,`required` 模式必须禁用 + `memory_workers_enabled`,不能保留部署根目录读权限。 + +Memory 内容仍可按产品策略共享;这只表示 Worker 不能直接读取其他对话的文件。若共享 +Memory 中也不能出现对话 A 的摘要或文件内容,必须另行启用 Memory 数据隔离。 + +### 9.5 Skill Manager 和其他直接文件工具 + +严格模式把共享 Skills 视为管理员管理的只读依赖。Agent 的 `skill_manager` 只保留 +`list`、`browse` 和 `info`;`install`、`uninstall` 及所有本地 source 一律拒绝。 + +因此 strict mode 中不存在通过 Skill 工具读取部署根目录、其他 scope 或宿主机路径的 +入口,也不依赖现有 HITL 范围。管理员安装或更新共享 Skill 使用独立的管理接口,不在 +对话 Agent 工具集中。未来若需要“从当前对话导入私有 Skill”,必须另立方案:接收 +`ToolRuntime`、只读取当前 `files/`、写入 scope 私有目录,并为该操作新增显式 HITL。 + +对所有非 backend 文件工具执行同一审计;任何使用 `WORKSPACE_ROOT`、 +`resolve_virtual_path()` 或固定 cwd 的请求路径必须改为 runtime scope 解析器,或在 +严格模式禁用。 + +### 9.6 后台进程 + +修改 BackgroundExecutionMiddleware: + +- `run_in_background` 从 `ToolRuntime` 解析 scope 并以当前 `files/` 为 cwd。 +- `BgProcess` 保存 `workspace_scope_id`,不能只保存 origin thread ID。 +- `check_process`、`stop_process` 和 `list_processes` 都接收 runtime 并校验 scope。 +- 移除 `all_threads=true` 的跨 scope 列表能力。 +- 日志存放位置属于当前 scope,其他 scope 返回 `not found`。 +- 启动后台容器前生成并登记 Registry owner UUID,将其写入容器 label;container ID 返回 + 后关联 owner。停止、完成和异常退出都原子更新 owner 状态。 + +`stop_process` 当前没有 `ToolRuntime` 参数,是必须修复的隔离缺口。 + +### 9.7 定时任务 + +定时任务记录增加不可变 `workspace_scope_id`: + +- `schedule_task`、`list_scheduled_tasks`、`cancel_scheduled_task` 都接收 ToolRuntime, + 从中读取 scope。 +- 创建 cron 时同时写入 `metadata.workspace_scope_id` 和 + `config.configurable.workspace_scope_id`。后者是 cron 触发 run 时选择 backend 的依据, + 不能只存 metadata。 +- Scheduler graph 使用运行时 backend factory;cron 触发的内部 thread 不得成为目录名。 +- `list_schedules(scope)` 使用 metadata 的 containment 查询;`cancel` 和 `run_now` 先 + 从 Registry 验证 cron owner,再操作。`run_now` 也必须传同一 config 与 metadata。 +- `SchedulerMiddleware` 的动态提示改为按 runtime scope 查询,缓存键为 + `scope_id + revision`,不得缓存或注入其他 scope 的任务名称、prompt 或 ID。 +- 创建 cron 前生成并登记 owner UUID,将其写入 cron config、metadata 和 Registry; + 返回 cron ID 后再把 cron ID 关联到该 owner。删除主对话时由 Registry 枚举、禁用并 + 删除该 scope 的 cron。 + +如果第一阶段不准备完成 Scheduler 隔离,应在严格模式下暂时禁止对话创建 schedule, +不能让它回落到公共 workspace。 + +## 10. 生命周期设计 + +### 10.1 打开和恢复 + +- 打开历史对话:BFF 查询 Registry 与 thread metadata;旧 thread 先 provision,再切换 scope。 +- 浏览器刷新:URL thread ID 只用于 BFF 查询,Registry 是 scope 的恢复来源。 +- 审批暂停后恢复:BFF 沿用已登记 run owner 的 scope,不读取 New Chat 状态。 +- 运行重连:发现的 active run metadata、Registry owner 或 thread metadata 任一不一致时停止 + 恢复并报告错误。 + +### 10.2 重命名 + +对话标题变化不改变 scope,也不重命名物理目录。目录永远使用不可变 UUID。 + +### 10.3 删除 + +当前 `useThreads.deleteThread()` 只删除 LangGraph thread,隔离后应由统一服务端接口 +编排对话和文件删除: + +```text +DELETE /api/conversations/ +``` + +建议流程: + +1. 将 thread 状态置为 `deleting`,拒绝新 run 和文件请求。 +2. Registry 以 compare-and-set 将 scope 改为 `deleting`,递增 revision 并冻结 owner 创建。 +3. 调用 `drain_scope(scope_id)`:按 owner registry 枚举主 run、async/memory thread、 + cron 和后台进程,取消或停止后等待终态。 +4. drain 超时、owner 未知或 Registry 与 thread metadata 不一致时保持 `deleting`,不移动 + 目录;修复任务完成对账后才能重试。 +5. 将 scope 目录原子重命名到 `trash/`,再删除 LangGraph thread 和已终态 owner 记录。 +6. 将 Registry 状态置为 `deleted`;失败时按阶段补偿,成功后异步清理回收目录。 + +文件系统和 LangGraph 存储之间没有跨系统事务,因此所有步骤必须幂等,并由孤儿 +清理任务处理残留。运行中的目录不能进入 purge;回收目录默认保留 7 天,产品可在 +配置中调整。 + +### 10.4 复制和分支 + +未来支持复制或分支对话时,默认创建新的 `workspace_scope_id`。文件策略必须显式 +选择: + +- `empty`:新对话使用空目录,推荐默认值。 +- `copy`:复制源对话文件到新 scope。 + +禁止两个主对话永久共享同一个可写 scope,否则不再满足隔离定义。 + +## 11. 全量切换和旧 workspace 迁移 + +现有部署根目录中的文件没有可靠的 thread 所有权信息。不能根据聊天文本或文件时间自动 +分配,更不能复制到每个对话,否则会扩大泄露。为保证“所有现有对话均已调整”,生产 +`required` 切换不用“首次打开时懒迁移”作为主路径。 + +### 11.1 必经批量切换 + +在 `optional` 模式执行可恢复的 `workspace-cutover` 管理任务,进度写入 Registry 操作表: + +1. 获取 Registry 中 `deployment_locks(deployment_id, "workspace-cutover")` 的排他租约。 + 锁存续期间 BFF 和所有内部 owner 创建入口拒绝新的 draft、run、resume、cron 和后台任务, + 返回 `503 cutover-in-progress`;锁租约必须由 `operation_id` 持有并可续期。随后盘点全部 + 主 thread、draft、active run、async/memory thread、cron、后台容器和 memory scheduler。 + cutover 的每次写入和续租都必须以 `operation_id` 与 `expires_at > now` 作为条件;条件失败 + 立即停止,避免失去租约的旧进程继续写入。 +2. 为每个主 thread 创建或验证唯一 scope、`files/` 和 primary owner,并原子写入 Registry + 与 thread metadata。未打开的历史对话同样必须处理。 +3. 依据 parent metadata 和运行记录把可证明归属的派生资源登记到相同 scope。无 parent、 + 无 scope、owner 冲突或无法证明归属的 active run、cron、后台进程和 worker 必须取消、 + 禁用并记录为 quarantine;不得继续在公共根目录执行。 +4. 旧根目录标记为只读 `Legacy shared workspace`,不再挂载给 Agent;历史文件不自动分配。 + 用户仅能通过“导入到当前对话”复制指定文件,导入记录源、目标 scope、时间和幂等键。 +5. 将报告原子写入 `.evoscientist/control/cutover-reports/.json`(权限 `0600`), + 在 operation 中保存其 SHA-256。报告包含盘点时间、主 thread/scope/primary owner 计数、 + active/quarantined owner 清单摘要、legacy cron/process 数、浏览器 SDK 静态门禁结果和 + Registry/metadata 对账结果。仅当计数相等、无未处理 legacy owner、无不一致且静态门禁 + 通过时报告为 `passed`;否则为 `failed`,禁止切到 `required`。 +6. 通过的报告写入后才释放 cutover 锁。`required` 启动时验证最新通过报告的 hash、部署 ID + 和所有 gate;验证失败则拒绝启动。锁异常过期时由恢复任务继续或将报告标记失败,不能 + 自动切换为 `required`。 + +`required` 模式不再为未知旧 thread 懒创建 scope,而是返回 `409 migration-required` 并由 +管理员重新运行 cutover。`provision_legacy_scope(thread_id)` 仅保留给 `optional` 的修复任务, +必须使用同一 Registry 事务和审计记录,不能绕过上述盘点。 + +当前实现的管理入口为: + +```bash +uv run python scripts/workspace_cutover.py \ + --workspace /absolute/deployment-workspace \ + --api-url http://127.0.0.1:6174 \ + --webui-root /absolute/path/to/EvoScientist-WebUI +``` + +该命令在 Registry 中持有并续租 `workspace-cutover` 租约;运行期间 BFF 和运行时 scope +解析器拒绝新 draft、run、cron 和派生 owner。它分页盘点主 thread、检测仍在运行的旧 run, +并对每个带 `workspace_scope_id` 的派生 thread 或 cron 以 Registry 的 +`resource_id -> owner -> scope` 映射对账。未登记、scope/owner/deployment 不一致或无效状态 +的 thread 会中断活动 run、移除 scope metadata 并写入 `workspace_quarantine`;对应 cron 会 +被禁用。没有 scope 的旧派生资源走相同 quarantine 流程。取消、标记或最终状态任一步失败 +都会保留失败报告,不能切换为 `required`。报告保存为 +`.evoscientist/control/cutover-reports/latest.json`,同时写入 `scope_operations` 的报告 SHA-256。 +`required` 启动会校验二者、部署 ID、浏览器 SDK 静态门禁和 `passed` 状态。严格执行器还会 +验证 Docker 与固定 digest 的 OCI 镜像已就绪,缺失时拒绝启动。 + +定期生命周期清理由部署管理员执行;建议每小时运行一次: + +```bash +uv run python scripts/workspace_maintenance.py \ + --workspace /absolute/deployment-workspace \ + --api-url http://127.0.0.1:6174 +``` + +它与 cutover 共享 Registry 生命周期租约;租约存续期间 BFF、Registry 与运行时拒绝新的 +draft、run、cron 和派生 owner。它仅删除超过 `EVOSCIENTIST_DRAFT_WORKSPACE_TTL_HOURS` +且没有活动 run、没有非主 owner 的 draft;删除先进入 `trash/`。超过 +`EVOSCIENTIST_WORKSPACE_TRASH_RETENTION_DAYS` 的非符号链接回收目录才会被物理清理。 + +### 11.2 旧文件与回滚 + +旧根目录在 cutover 后至少保留到管理员确认导入窗口结束;它不属于任何 scope。回滚至 +`legacy` 只允许在维护窗口进行,并先停止 required 模式产生的容器、cron 和 run,避免两个 +目录模型同时写入。完成全量导入或达到保留期后再归档旧目录。 + +若当前部署 workspace 本身是一个代码项目,第一阶段也不自动挂载。确实需要公共 +项目源码时,再增加只读 `/project/` backend,并确保 `.evoscientist/`、Secrets、 +配置凭据和其他对话目录永远不可见。 + +## 12. 安全要求 + +### 12.1 路径安全 + +在现有 `resolveInside()` 和 `safeResolve()` 基础上保留并扩展: + +- 规范化路径分隔符。 +- 拒绝控制字符、`..`、隐藏内部目录和绝对路径覆盖。 +- 读取前 `realpath`,再次检查是否位于当前 `files/`。 +- 创建新文件时检查父目录真实路径,防止通过已有符号链接逃逸。 +- 上传使用排他创建或原子重命名,避免并发覆盖。 +- 下载 zip 只在当前 `files/` 执行,不跟随符号链接。 + +### 12.2 dangerous_mode + +当前 `dangerous_mode` 允许 Agent 接触真实文件系统,与严格对话隔离互斥。启用 +`conversation workspace isolation = required` 时,后端应在启动阶段拒绝 +`dangerous_mode=true`,而不是只在 UI 显示警告。 + +### 12.3 宿主机执行隔离 + +`CustomSandboxBackend` 的路径重写和命令正则校验只能降低误操作,不能限制运行在同一 +Unix 用户下的解释器主动枚举父目录、HOME 或其他进程环境。因此它不能单独作为 +conversation isolation 的安全边界。 + +`required` 模式固定使用容器化 `ScopedExecutor`: + +```text +host /files/ -> container /workspace (read-write) +shared skills/memories -> 不默认挂载;仅通过受控工具访问 +deployment workspace -> 不挂载 +other conversation roots -> 不挂载 +host HOME / Docker socket -> 不挂载 +``` + +- `execute` 在短生命周期容器中运行,容器标签包含 scope 和 owner ID。 +- `run_in_background` 创建同一策略的受管后台容器,Registry 保存 container ID;不再 + 直接使用宿主机 `subprocess.Popen`。 +- 容器镜像、网络策略、CPU/内存/进程限制和允许的环境变量由服务端配置,Agent 不能 + 覆盖。默认网络关闭;需要联网的明确工具使用受控 egress 策略。 +- Docker 或兼容 OCI runtime 不可用、镜像未验证或 daemon 不可达时,`required` 模式 + 启动失败;只能显式使用 `optional`/`legacy`,不得悄悄回退到宿主机执行。 +- 文件读写工具仍只以当前 `files/` 为 root;容器执行器解决的是 shell、解释器和后台 + 进程绕过该 root 的问题。 + +### 12.4 MCP 工具 + +Agent backend 只能约束经过该 backend 的文件和 shell 工具。具有独立宿主机文件 +权限的 MCP Server 可能绕过 scope。 + +严格模式采用拒绝优先策略。MCP 配置在启动时按能力分类,并写入审计日志: + +| MCP 类型 | `required` 模式 | +| ------------------------------------------------- | ------------------------------------ | +| 仅远程业务 API、无本地文件或进程能力 | 允许,按显式 allowlist 加载 | +| 本地文件、shell、项目目录、代码解释器或可传入 cwd | 禁用 | +| 连接在进程启动时固定本地根目录的 MCP | 禁用,不能靠“启动时传当前 scope”复用 | +| 已实现每次调用 runtime scope 校验的 MCP adapter | 允许,但需独立安全测试 | + +当前 MCP 工具缓存是进程级的,因此 v1 不实现每个 scope 启动独立 MCP 会话。任何不在 +allowlist 的 MCP 都使 `required` 模式启动失败;管理员必须先移除或显式标记为纯远程 +API。未来增加动态 MCP adapter 时,adapter 必须接收 `ScopeContext`,不得只在启动时 +保存 root directory。 + +### 12.5 原生 Code Interpreter + +项目的 `EvoCodeInterpreterMiddleware` 是原生 QuickJS middleware,不属于 MCP。它目前可 +批量调用 `read_file`、`grep`、`glob`、`ls` 和 async task 工具,因此必须纳入 scope 传播, +不能仅依赖 MCP allowlist。 + +`required` 模式默认 `EVOSCIENTIST_STRICT_CODE_INTERPRETER=disabled`。只有满足以下条件才 +允许配置为 `scoped`:QuickJS 的每个 PTC 工具调用均保留原始 `ToolRuntime`,所有被允许的 +工具在执行前调用 `require_scoped_runtime()`,异步创建仍登记 child owner,并且启动自检与 +验收测试通过。任一条件失败时拒绝 `scoped` 配置,不能退化为未校验执行。Code Interpreter +不得暴露 Node、Python、宿主机文件系统或任意网络模块;其可调用工具表在严格模式必须是 +服务端固定 allowlist。 + +### 12.6 共享 Memory 和 Skills + +`/memories/` 和 `/skills/` 是显式共享路由。它们可以让不同对话获得共同知识,但不 +是私有 `files/` 的文件系统挂载。Memory Worker、Skill Manager 和其他直接文件工具 +仍必须使用当前 scope;共享路由本身不能成为读取部署根目录的理由。 + +如果产品要求“任何信息都不能跨对话”,需要另立 Memory 隔离方案,包括 Memory +Worker、自动技能和摘要数据;本方案不应被描述为已经满足该更强目标。 + +### 12.7 Scope 来源和 deployment 一致性 + +- 浏览器不提交 `workspace_scope_id`,也不能通过 query/header 选择 deployment 根目录。 +- WebUI 对话服务从服务端 `ActiveDeployment` 查询主 thread,再生成或恢复 scope。 +- 主图后端验证 runtime 的实际 thread ID 属于 Registry 中该 scope 的 primary thread。 +- 派生图只接受内部传播的 scope,并验证 config 与 metadata 完全一致。 +- sidecar 的 deployment ID、LangGraph API 地址和 workspace 根目录不一致时,所有 + Workspace API 和新 run 失败关闭。 + +## 13. 配置和发布策略 + +所有部署配置写入后端或 WebUI 的 `.env`;不需要通过 CLI 保存工作目录或隔离模式。 +未设置 `EVOSCIENTIST_WORKSPACE_ISOLATION` 时默认值为 `optional`,因此新建的 WebUI +对话会自动拥有独立的 `files/` 和 `runtime/` 目录。`legacy` 仅用于显式回退,`required` +是需要额外运行时加固的严格模式。 + +后端 `.env` 的常用配置如下: + +```text +EVOSCIENTIST_WORKSPACE_DIR=/absolute/path/to/workspace +EVOSCIENTIST_WORKSPACE_ISOLATION=optional +EVOSCIENTIST_SCOPE_REGISTRY_TOPOLOGY=single-host +EVOSCIENTIST_DRAFT_WORKSPACE_TTL_HOURS=24 +EVOSCIENTIST_WORKSPACE_TRASH_RETENTION_DAYS=7 +``` + +同机部署不需要手工填写 `EVOSCIENTIST_BACKEND_SERVICE_TOKEN`:`EvoSci deploy` 会在 +`/.evoscientist/control/` 生成仅服务端读取的令牌,并传给后端;独立 WebUI +也会从该位置读取。仅在 WebUI 与后端无法共享该目录时,才在两端 `.env` 中配置相同的 +服务端密钥。严格模式另行在 `.env` 中设置: + +```text +EVOSCIENTIST_WORKSPACE_ISOLATION=required +EVOSCIENTIST_STRICT_EXECUTOR=oci +EVOSCIENTIST_STRICT_EXECUTOR_IMAGE= +EVOSCIENTIST_STRICT_CODE_INTERPRETER=disabled|scoped +``` + +v1 保留现有单次上传限制,但不承诺“每 scope 总容量配额”。Agent shell、文件工具和 +后台进程都可写文件,仅在上传 API 计数无法形成可靠配额。总容量配额需另行采用 +文件系统 project quota 或覆盖所有写入面的统一计量后再发布。 + +模式定义: + +| 模式 | 行为 | +| ---------- | -------------------------------------------------------------------------------------------------------------------- | +| `legacy` | 完全保持现有公共根目录,仅用于回滚 | +| `optional` | WebUI 默认模式:新对话必须创建 scope 并隔离;scope 服务、令牌或 Registry 不可用时拒绝创建线程、Run 和 Workspace 请求 | +| `required` | 只接受 BFF 和内部服务凭据;缺少、非法或 deleting scope 立即失败 | + +发布顺序: + +1. 后端和 API 先支持 `optional`,完成双路径自动化测试。 +2. WebUI 改为仅使用 BFF,完成所有调用入口映射,并通过“无浏览器 LangGraph SDK”静态门禁。 +3. 完成后台任务、Scheduler、Code Interpreter 和 MCP 审计;任何无法传播 scope 的能力在 + `required` 禁用。 +4. 执行全量 `workspace-cutover`,处理全部历史主 thread 和已存在 owner,生成通过的报告。 +5. 通过日志确认没有 WebUI run 缺少 scope、没有 legacy owner、没有 Registry/metadata 不一致。 +6. 切换为 `required`。 + +正式发布不能长期停留在 `optional`,因为遗漏传播时会静默落入公共目录。 + +## 14. 实施拆分 + +### 阶段 A:Scope 基础设施 + +- WebUI 增加 thread scope 类型、UUID 校验、`ActiveDeployment` 和服务端目录解析器。 +- WebUI 增加服务端创建 thread/run/resume 的对话接口,严格模式移除浏览器直连的 + LangGraph SDK、`useStream`、线程列表、run 查询、异步代理和定时任务路径。 +- EvoScientist 增加 Scope Registry、内部服务凭据校验、`workspace_scope.py` 和运行时 + backend 工厂。 +- 验证 OCI runtime、固定执行镜像和最小挂载;缺失时拒绝启用 `required`。 +- 增加配置开关和启动期 `dangerous_mode` 冲突校验。 +- 保留 `/skills/`、`/memories/` 现有路由。 + +### 阶段 B:传播完整性 + +- 主 run、resume run 写入 config 和 metadata。 +- 同步子代理验证继承。 +- 异步子代理显式传播。 +- Memory Worker、Observation Linker、AutoSkills 和 memory scheduler 显式传播,或在 + `required` 模式禁用。 +- `skill_manager` 在严格模式禁用安装与卸载;其他直接文件工具切换至 runtime scope + 解析器或禁用。 +- Code Interpreter 默认禁用;启用时验证其 PTC 工具保留 `ToolRuntime` 并覆盖同一 scope + 测试矩阵。 +- 后台进程 cwd、日志和所有权隔离。 +- Scheduler 在 cron payload 的 config 和 metadata 中保存并继承 scope,系统提示缓存按 + scope 隔离。 +- MCP 在启动期按能力 allowlist 验证,拒绝本地文件和进程能力。 + +阶段 B 完成前不能对外宣称已实现严格隔离。 + +### 阶段 C:WebUI 文件体验 + +- 实现 `ensureThread()` 和 draft thread。 +- 修改上传、列表、预览、编辑、删除和下载 API。 +- 修改 WorkspacePanel、WorkspaceFileDialog 和 ResearchDashboard。 +- 历史列表隐藏 draft,对话激活后正常显示。 + +### 阶段 D:生命周期和迁移 + +- 实现统一删除接口、active run 排空、trash 和孤儿目录清理。 +- 实现 `workspace-cutover` 全量盘点、owner quarantine、报告和旧文件显式导入。 +- 仅在 cutover 报告和浏览器 SDK 静态门禁均通过后切换生产配置为 `required`。 + +## 15. 预计修改文件 + +### EvoScientist-WebUI + +新增: + +```text +src/lib/workspaceScope.ts +src/lib/server/activeDeployment.ts +src/lib/server/conversationWorkspace.ts +src/lib/server/scopeRegistryClient.ts +src/lib/conversationApi.ts +src/lib/conversationTypes.ts +scripts/check-no-browser-langgraph-client.mjs +src/app/api/conversations/route.ts +src/app/api/conversations/[threadId]/route.ts +src/app/api/conversations/[threadId]/file-state/route.ts +src/app/api/conversations/[threadId]/export/route.ts +src/app/api/conversations/[threadId]/runs/route.ts +src/app/api/conversations/[threadId]/runs/[runId]/route.ts +src/app/api/conversations/[threadId]/runs/[runId]/cancel/route.ts +src/app/api/conversations/[threadId]/runs/[runId]/stream/route.ts +src/app/api/conversations/[threadId]/async-tasks/route.ts +src/app/api/conversations/[threadId]/async-tasks/[taskId]/route.ts +src/app/api/conversations/[threadId]/async-tasks/[taskId]/runs/route.ts +src/app/api/conversations/[threadId]/schedules/route.ts +src/app/api/conversations/[threadId]/schedules/[scheduleId]/route.ts +src/app/api/conversations/[threadId]/schedules/[scheduleId]/run/route.ts +src/app/api/deployment/assistant/route.ts +``` + +修改: + +```text +src/lib/server/workspace.ts +src/app/api/workspace/route.ts +src/app/api/workspace/file/route.ts +src/app/api/workspace/upload/route.ts +src/app/api/workspace/download/route.ts +src/app/hooks/useChat.ts +src/app/hooks/useThreads.ts +src/app/hooks/useAsyncAgents.ts +src/app/hooks/useScheduledTasks.ts +src/lib/modelCommand.ts +src/lib/runRecovery.ts +src/app/components/ChatInterface.tsx +src/app/components/AgentsPanel.tsx +src/app/components/ScheduledTasksPanel.tsx +src/app/components/WorkspacePanel.tsx +src/app/components/WorkspaceFileDialog.tsx +src/app/components/ResearchDashboard.tsx +src/app/components/ThreadList.tsx +src/app/page.tsx +src/providers/ChatProvider.tsx +src/app/components/ConfigDialog.tsx +src/providers/ClientProvider.tsx(删除,替换为 ConversationApi) +``` + +### EvoScientist + +新增: + +```text +EvoScientist/workspace_scope.py +EvoScientist/scope_registry.py +EvoScientist/execution/scoped_executor.py +``` + +修改范围: + +```text +EvoScientist/EvoScientist.py +EvoScientist/llm/patches.py +EvoScientist/config/settings.py +EvoScientist/gateway/server.py +EvoScientist/langgraph_dev/manager.py +EvoScientist/langgraph_dev/graphs.py +EvoScientist/backends.py +EvoScientist/mcp.py +EvoScientist/middleware/code_interpreter.py +EvoScientist/middleware/background.py +EvoScientist/background.py +EvoScientist/middleware/scheduler.py +EvoScientist/cron/schedule.py +EvoScientist/middleware/memory_lifecycle.py +EvoScientist/memory/launch.py +EvoScientist/memory/scheduler.py +EvoScientist/memory/agents/_factory.py +EvoScientist/memory/agents/memory_worker.py +EvoScientist/tools/skill_manager.py +EvoScientist/tools/skills_manager.py +EvoScientist/cli/workspace_cutover.py +EvoScientist/paths.py(仅保留部署根目录能力,不用于运行时切换) +``` + +实际实施前应再次搜索所有 `WORKSPACE_ROOT`、`getWorkspaceDir()`、 +`resolve_virtual_path()`、`set_active_workspace()`、`threads.create()`、`runs.create()`、 +`runs.wait()`、`runs.get()`、`runs.list()`、`threads.getState()`、`threads.updateState()`、 +`crons.*()`、`assistants.get()`、`assistants.search()`、`new Client()`、`useClient()` 和 +`useStream()` 调用,不能只修改上述已知入口。 + +## 16. 验收测试 + +### 16.1 路径和 API + +1. A 上传 `result.txt`,A 可以列表、读取、编辑和下载。 +2. B 上传同名 `result.txt`,A、B 内容互不覆盖。 +3. 用 B 请求 A 的虚拟路径返回 `404`。 +4. `../`、绝对路径、控制字符和符号链接逃逸全部失败。 +5. 下载全部只包含当前 scope 文件。 +6. API 响应和日志不向浏览器暴露物理根目录。 +7. 浏览器传入其他 deployment URL 或伪造 scope 时,服务端拒绝而不访问本地文件。 +8. 浏览器直连 LangGraph API、携带公开 API key 或直接提交 `config`/`metadata` 均失败; + 同一 `run_request_id` 的 BFF 重试只创建一个 run,而同一 `turn_id` 的审批恢复可创建 + 独立 run。 +9. 线程重命名、置顶、模型选择和文件附件状态均只能经过 BFF;伪造的 metadata、文件路径 + 或 model 不修改线程。重新打开后仍使用保存的 allowlist 模型。 +10. 线程导出仅返回当前主 thread 的脱敏状态,不能通过 export 获取派生 thread 或物理目录。 +11. 静态门禁在浏览器 bundle 中发现 LangGraph Client、`useStream`、公开 API key 或 + deployment URL 时失败;服务端 BFF SDK 未被误判。 + +### 16.2 对话生命周期 + +1. New Chat 上传前只创建一个 draft thread。 +2. 上传和发送同时发生时仍只使用一个 thread/scope。 +3. 第一条消息后 draft 转为 active,历史列表只出现一次。 +4. 刷新、重新打开和审批恢复后 scope 不变。 +5. 删除时 active run、后台进程和 schedule 先终止;仍在运行时保持 `deleting`。 +6. 删除对话后不能再读文件,trash 按策略清理。 +7. 过期 draft 的 thread 和目录都被清理。 +8. 删除使用 Registry 枚举并 drain async thread、Memory Worker、cron 与后台进程;未知 + owner 时不删除目录。 +9. cutover 为所有历史主 thread(包含从未打开的 thread)建立唯一 scope;报告中的 thread + 数、scope 数和 primary owner 数一致。 +10. 无法归属的 legacy run、cron、后台进程和 worker 均被 quarantine,`required` 切换被 + 拒绝直至报告无遗留项;未知旧 thread 在 `required` 返回 `migration-required`。 +11. cutover 租约存续时 BFF 与内部创建入口均返回 `503 cutover-in-progress`;报告 hash、 + deployment ID 和全部 gate 在 `required` 启动前重新验证。 +12. 带 scope metadata 的派生 thread 和 cron 必须匹配 Registry 的 resource owner;不匹配的 + 资源被 quarantine/禁用,scope metadata 不得继续保留在可运行线程上。 + +### 16.3 Agent 执行 + +1. A、B 并发让 Agent 写 `/result.txt`,实际落入两个目录。 +2. 主代理无法通过 `ls`、文件工具或 shell 看到其他 scope。 +3. 同步子代理读写父 scope。 +4. 异步子代理拥有不同 thread ID,但读写父 scope。 +5. 后台进程以当前 scope 为 cwd;B 不能查询、停止或读取 A 的日志。 +6. Memory Worker、Observation Linker 和 AutoSkills 只能读取父 scope,不能列举其他 + conversation 目录。 +7. 严格模式中 `skill_manager` 的安装和卸载一律失败,不能解析任何本地 source。 +8. Scheduler 的 cron payload 同时包含 scope config 和 metadata,恢复执行后仍写入创建 + 它的 scope。 +9. A 的 Scheduler 系统提示、列表和取消操作均不能看到或操作 B 的 cron。 +10. 在 A 的 `execute` 与后台容器中使用 Python 的 `Path.home()`、父目录枚举、环境变量 + 和绝对宿主机路径均无法读取 B 或部署根目录。 +11. 启用 scoped Code Interpreter 时,从 JS 调用 `ls`、`read_file`、`glob` 和 + `start_async_task` 都只使用父 scope;运行时缺失或伪造 scope 时全部失败。禁用配置下 + 不注册 `code_interpreter` 工具。 + +### 16.4 安全和回归 + +1. `required` 模式缺少 scope 时 run 失败,不回落公共目录。 +2. `required + dangerous_mode` 启动失败并给出明确错误。 +3. 所有启用的 MCP 通过能力审计。 +4. `/skills/`、`/memories/` 可按共享策略使用,但不允许任何工具跳转到 conversations + 目录。 +5. 浏览器不能直接创建带 scope 的主 run;严格模式中的 run 均由服务端对话接口创建。 +6. `required` 模式拒绝本地文件/进程 MCP,并拒绝 Agent 的 Skill 安装和卸载。 +7. OCI runtime、镜像摘要或容器最小挂载校验失败时,`required` 模式拒绝启动。 +8. WebUI 绑定非 loopback 且未启用认证时,`required` 模式拒绝启动。 +9. 模型选择、三种审核模式、Token 统计和断线恢复不因新增 scope 回归。 +10. 对同一 interrupt 在执行过程中分别提交 approve、reject 和编辑后批准,BFF 只恢复 + 第一项对应 scope/run,后续提交返回 `interrupt_already_resolved`;初始 review_mode 不会 + 自动批准已发出的 interrupt。 +11. Registry schema 迁移、revision CAS、owner 外部资源唯一性、run request 幂等冲突和崩溃后的 + saga 补偿均有单元测试与断电恢复集成测试。 +12. 历史加载、SSE 断线续接和无法续接后的 `ThreadSnapshot` 回读都只经过 BFF,且审批卡、 + Token 统计和消息顺序与当前行为一致。 + +## 17. 可观测性 + +日志可以记录: + +```text +thread_id +workspace_scope_id +run_id +operation +virtual_path +result +``` + +不得记录上传文件内容、Secret 或完整物理路径。建议增加以下指标: + +- 缺少 scope 的 WebUI run 数量。 +- scope/config/metadata 不一致数量。 +- 按 scope 的文件数量和字节数。 +- draft、trash 和孤儿目录数量。 +- 被拒绝的跨目录、符号链接和后台进程访问次数。 + +## 18. 默认产品决策 + +若没有额外产品要求,实施时采用以下默认值: + +- 私有范围:上传文件、Agent 生成文件、后台日志。 +- 共享范围:Skills、Memories。 +- 浏览器权限:只提交白名单业务字段,不创建带 scope 的主 run;严格模式由服务端对话 + 接口创建 draft、run、resume、异步代理查询和定时任务;浏览器不保有 LangGraph 凭据。 +- 模型选择:服务端 allowlist 校验后以 `model_override` 存入当前主 thread,重开对话沿用。 +- 审核选择:每个 interrupt 单独决定 approve、reject 或编辑后批准,不在对话开始时锁定。 +- Memory Worker:只读当前 scope 的 `files/`;无法完成运行时改造时在严格模式禁用。 +- Skills:严格模式下 Agent 只能浏览已安装的共享 Skills,不能安装或卸载。 +- MCP:严格模式仅允许服务端 allowlist 中的纯远程 API MCP。 +- 命令执行:严格模式要求 OCI 容器执行器,只挂载当前 scope 的 `files/`。 +- New Chat:首次上传或发送时预创建 draft thread。 +- 草稿 TTL:24 小时。 +- 删除回收:7 天。 +- 旧文件:只允许用户显式导入,不自动分配。 +- 历史对话:切换 `required` 前批量创建 scope、登记或 quarantine 全部 owner;未知旧 thread + 不可运行,不能按首次打开懒迁移。 +- 对话复制:新建空目录,不共享可写目录。 +- 公共项目目录:默认不挂载;需要时单独只读挂载。 +- WebUI 默认:`EVOSCIENTIST_WORKSPACE_ISOLATION=optional`,新对话隔离;scope 服务、 + 令牌或 Registry 不可用时拒绝操作。需要公共目录兼容时,管理员必须显式设置 `legacy`。 +- 高安全部署:在 `.env` 显式设置 `EVOSCIENTIST_WORKSPACE_ISOLATION=required`,并完成 + 全量 cutover、镜像摘要校验和严格执行器配置。 + +上述默认值能在不引入多用户权限系统的前提下,实现当前 WebUI 单用户部署中的 +可靠会话文件隔离,并为后续用户级 ACL 和更严格的 Memory 隔离保留扩展位置。 diff --git a/docs/office-preview.md b/docs/office-preview.md new file mode 100644 index 0000000..4092565 --- /dev/null +++ b/docs/office-preview.md @@ -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. diff --git a/docs/schemas/fixtures/accepted/confirmed.json b/docs/schemas/fixtures/accepted/confirmed.json new file mode 100644 index 0000000..c67cc0e --- /dev/null +++ b/docs/schemas/fixtures/accepted/confirmed.json @@ -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" +} diff --git a/docs/schemas/fixtures/accepted/unknown.json b/docs/schemas/fixtures/accepted/unknown.json new file mode 100644 index 0000000..0408bee --- /dev/null +++ b/docs/schemas/fixtures/accepted/unknown.json @@ -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" +} diff --git a/docs/schemas/fixtures/identity/workspace-posix.json b/docs/schemas/fixtures/identity/workspace-posix.json new file mode 100644 index 0000000..5968bcc --- /dev/null +++ b/docs/schemas/fixtures/identity/workspace-posix.json @@ -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" +} diff --git a/docs/schemas/fixtures/identity/workspace-root-and-symlink.json b/docs/schemas/fixtures/identity/workspace-root-and-symlink.json new file mode 100644 index 0000000..ab1f8b1 --- /dev/null +++ b/docs/schemas/fixtures/identity/workspace-root-and-symlink.json @@ -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" +} diff --git a/docs/schemas/fixtures/identity/workspace-unicode.json b/docs/schemas/fixtures/identity/workspace-unicode.json new file mode 100644 index 0000000..ea097b0 --- /dev/null +++ b/docs/schemas/fixtures/identity/workspace-unicode.json @@ -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" +} diff --git a/docs/schemas/fixtures/identity/workspace-windows.json b/docs/schemas/fixtures/identity/workspace-windows.json new file mode 100644 index 0000000..a9a9556 --- /dev/null +++ b/docs/schemas/fixtures/identity/workspace-windows.json @@ -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" + } + ] +} diff --git a/docs/schemas/fixtures/metadata/async-subagent.json b/docs/schemas/fixtures/metadata/async-subagent.json new file mode 100644 index 0000000..08a2952 --- /dev/null +++ b/docs/schemas/fixtures/metadata/async-subagent.json @@ -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" + } +} diff --git a/docs/schemas/fixtures/metadata/memory.json b/docs/schemas/fixtures/metadata/memory.json new file mode 100644 index 0000000..e70ea15 --- /dev/null +++ b/docs/schemas/fixtures/metadata/memory.json @@ -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" + } +} diff --git a/docs/schemas/fixtures/metadata/new-message.json b/docs/schemas/fixtures/metadata/new-message.json new file mode 100644 index 0000000..84ad66d --- /dev/null +++ b/docs/schemas/fixtures/metadata/new-message.json @@ -0,0 +1,8 @@ +{ + "submit_location": "options.metadata", + "metadata": { + "usage_context_version": 1, + "turn_id": "human-message-fixture" + }, + "config_metadata_forbidden": true +} diff --git a/docs/schemas/fixtures/metadata/resume.json b/docs/schemas/fixtures/metadata/resume.json new file mode 100644 index 0000000..e1411ef --- /dev/null +++ b/docs/schemas/fixtures/metadata/resume.json @@ -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" + } +} diff --git a/docs/schemas/fixtures/projection/conflict.json b/docs/schemas/fixtures/projection/conflict.json new file mode 100644 index 0000000..85f8ba3 --- /dev/null +++ b/docs/schemas/fixtures/projection/conflict.json @@ -0,0 +1,9 @@ +{ + "sequence": [ + "accepted/confirmed.json", + "projection/conflicting-confirmed.json" + ], + "expected_statuses": ["accepted", "conflict"], + "expected_confirmed_calls": "1", + "expected_total_tokens": "1200" +} diff --git a/docs/schemas/fixtures/projection/conflicting-confirmed.json b/docs/schemas/fixtures/projection/conflicting-confirmed.json new file mode 100644 index 0000000..f7b6701 --- /dev/null +++ b/docs/schemas/fixtures/projection/conflicting-confirmed.json @@ -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" +} diff --git a/docs/schemas/fixtures/projection/idempotency.json b/docs/schemas/fixtures/projection/idempotency.json new file mode 100644 index 0000000..2aef890 --- /dev/null +++ b/docs/schemas/fixtures/projection/idempotency.json @@ -0,0 +1,6 @@ +{ + "sequence": ["accepted/confirmed.json", "accepted/confirmed.json"], + "expected_statuses": ["accepted", "duplicate"], + "expected_confirmed_calls": "1", + "expected_total_tokens": "1200" +} diff --git a/docs/schemas/fixtures/projection/unknown.json b/docs/schemas/fixtures/projection/unknown.json new file mode 100644 index 0000000..d8be943 --- /dev/null +++ b/docs/schemas/fixtures/projection/unknown.json @@ -0,0 +1,6 @@ +{ + "sequence": ["accepted/unknown.json"], + "expected_confirmed_calls": "0", + "expected_unknown_calls": "1", + "expected_total_tokens": "0" +} diff --git a/docs/schemas/fixtures/providers/compatibility.json b/docs/schemas/fixtures/providers/compatibility.json new file mode 100644 index 0000000..f4e17ec --- /dev/null +++ b/docs/schemas/fixtures/providers/compatibility.json @@ -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" + } + ] +} diff --git a/docs/schemas/fixtures/query/bigint.json b/docs/schemas/fixtures/query/bigint.json new file mode 100644 index 0000000..27d7123 --- /dev/null +++ b/docs/schemas/fixtures/query/bigint.json @@ -0,0 +1,5 @@ +{ + "input_rows": ["9007199254740991", "9007199254740991"], + "expected_decimal_sum": "18014398509481982", + "json_number_for_aggregate_forbidden": true +} diff --git a/docs/schemas/fixtures/rejected/unsafe-token-sum.json b/docs/schemas/fixtures/rejected/unsafe-token-sum.json new file mode 100644 index 0000000..19de310 --- /dev/null +++ b/docs/schemas/fixtures/rejected/unsafe-token-sum.json @@ -0,0 +1,5 @@ +{ + "base_fixture": "../accepted/confirmed.json", + "patch": { "input_tokens": 9007199254740991, "output_tokens": 1 }, + "expected_reason": "token_sum_exceeds_safe_integer" +} diff --git a/docs/schemas/fixtures/spool/ack-crash.json b/docs/schemas/fixtures/spool/ack-crash.json new file mode 100644 index 0000000..c47f67d --- /dev/null +++ b/docs/schemas/fixtures/spool/ack-crash.json @@ -0,0 +1,5 @@ +{ + "crash_point": "after_http_200_before_inflight_delete", + "recovery": "replay_inflight_after_lease", + "collector_result": "duplicate" +} diff --git a/docs/schemas/implementation-evidence.md b/docs/schemas/implementation-evidence.md new file mode 100644 index 0000000..81692ba --- /dev/null +++ b/docs/schemas/implementation-evidence.md @@ -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. diff --git a/docs/schemas/provider-reconciliation-capabilities.md b/docs/schemas/provider-reconciliation-capabilities.md new file mode 100644 index 0000000..c795938 --- /dev/null +++ b/docs/schemas/provider-reconciliation-capabilities.md @@ -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. diff --git a/docs/schemas/provider-usage-compatibility.md b/docs/schemas/provider-usage-compatibility.md new file mode 100644 index 0000000..066740f --- /dev/null +++ b/docs/schemas/provider-usage-compatibility.md @@ -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`. diff --git a/docs/schemas/usage-api-v1.md b/docs/schemas/usage-api-v1.md new file mode 100644 index 0000000..7ac5e17 --- /dev/null +++ b/docs/schemas/usage-api-v1.md @@ -0,0 +1,35 @@ +# Usage API v1 + +该契约服务于同机集成启动模式。`events`、`sources/heartbeat` 和 `capabilities` 使用 +`Authorization: Bearer `;该 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。 diff --git a/docs/schemas/usage-event-v1.schema.json b/docs/schemas/usage-event-v1.schema.json new file mode 100644 index 0000000..d43fe2b --- /dev/null +++ b/docs/schemas/usage-event-v1.schema.json @@ -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" } + } + } + } + ] +} diff --git a/docs/schemas/usage-spool-v1.md b/docs/schemas/usage-spool-v1.md new file mode 100644 index 0000000..0a8c84b --- /dev/null +++ b/docs/schemas/usage-spool-v1.md @@ -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。 diff --git a/docs/token统计方案.md b/docs/token统计方案.md new file mode 100644 index 0000000..e8ed92a --- /dev/null +++ b/docs/token统计方案.md @@ -0,0 +1,1267 @@ +# EvoScientist-WebUI Token 统计方案 + +> 版本:2.0 | 日期:2026-07-16 | 所属项目:EvoScientist-WebUI +> 状态:阶段零至阶段二已实施并具备自动化验收;阶段三按当前 Provider 聚合能力实现差异报告,不伪造逐请求对账 + +## 1. 决策摘要 + +Token 统计的核心不是按对话、SSE 消息或最终回答统计,而是按每一次真实模型调用统计。 + +每次主代理、子代理、摘要器、Tool Selector、Memory Worker、Scheduler 和 fallback 实际调用模型时,都产生独立的 `model_call_id` 和 usage 记录。v1 只接收模型进程终态 callback 产生的 `callback_final`,不接收 partial、Gateway 或供应商对账事件,从源头避免同权威来源竞争。 + +2.0 采用以下项目边界: + +- EvoScientist-WebUI 是 Token 统计功能的所有者。 +- EvoScientist 只运行一个无业务聚合逻辑的轻量采集探针。 +- Token 事件接收、持久化、权威投影和聚合查询均位于 EvoScientist-WebUI。 +- WebUI 不从聊天文本或 SSE 累加 Token。 +- 没有供应商真实 usage 的调用标记为 `unknown`,不估算,也不计入已确认总量。 +- Agent scope 使用显式 `usage_scope` 和受测试的 metadata 映射,不依赖模糊推断。 +- 不新增用户、角色或 workspace 权限模型;Token 统计按当前 WebUI 的单用户部署边界运行。 +- 阶段零先冻结跨语言协议、共享 fixtures 和运行参数,再并行开发 Python sender 与 TypeScript Collector。 + +```text +EvoScientist-WebUI 创建 turn_id + -> LangGraph run metadata + -> EvoScientist UsageCaptureCallback + -> UsageEvent 上报 + -> EvoScientist-WebUI Usage Projector + -> Token 查询和展示 +``` + +## 2. 目标和非目标 + +### 2.1 目标 + +1. 覆盖所有真实模型调用,而不只是主代理最终回答。 +2. 同一个模型调用重复上报时只统计一次。 +3. 按线程、回合、Agent scope、Provider 和模型聚合,并保证 scope 归类可验证。 +4. 原始 usage 观测与权威 Token 投影分离。 +5. 供应商 usage 对账修正保留审计轨迹。 +6. EvoScientist 的模型执行、流式输出和 Agent 行为不受统计故障影响。 +7. 第一阶段尽量减少对 EvoScientist 的修改范围。 + +### 2.2 非目标 + +第一阶段不处理: + +- 为所有 OpenAI-compatible 网关强制开启 `stream_usage`。 +- 基于 prompt 长度估算缺失 Token。 +- 修改现有 CLI/TUI 的临时 usage 展示。 +- 将所有 Provider 强制迁移到统一 LLM Gateway。 +- 将 Token 统计与 checkpoint 生命周期绑定。 +- 新增用户、角色、workspace ACL 或多租户权限控制。 + +## 3. 核心原则 + +### 3.1 一次真实调用一条记录 + +一次回合如果发生: + +- 2 次主代理调用 +- 3 次子代理调用 +- 1 次摘要调用 +- 2 次 fallback 尝试 + +则记录 8 个模型调用,再汇总 8 条最终 usage。不能只记录一条“本回合总 Token”。 + +### 3.2 使用 LangChain run_id 幂等 + +callback 中的 LangChain `run_id` 作为 `model_call_id`。v1 的 `source=callback_final`、`revision=1` 均为常量,同一个 callback 事件因网络重试重复上报时,接收端通过 `deployment_id + model_call_id + callback_final + 1` 幂等处理。 + +供应商返回的 `provider_request_id` 单独保存,用于后续供应商 usage 记录匹配,不能取代本地 `model_call_id`。 + +### 3.3 同一调用的版本不相加 + +v1 对每个模型调用只接受一个来源: + +```text +source = callback_final +authority_class = observed_final +``` + +- 流式 partial usage 只在 callback 内存中缓存,用于构造终态事件,不单独上报。 +- Gateway usage 不进入 v1 Inbox,也不参与投影。 +- 供应商对账留到阶段三,由 WebUI 内部任务生成;普通 sender 和 `/api/usage/events` 不能提交对账来源。 +- 相同 event ID 出现不同 payload 时进入 `conflict`,投影保持原值,不能采用最后写入者。 + +如果未来需要多个观测来源,必须发布新的 schema version,并把来源策略作为不可变 `projection_policy_version` 保存;不能读取当前可变 Provider Profile 后重算历史。 + +### 3.4 unknown 不等于零 + +模型正常结束或触发 `on_llm_error`,但供应商没有返回 usage 时,记录: + +```text +usage_status = unknown +``` + +`unknown` 调用: + +- 不进入已确认 Token 总量。 +- 不作为零 Token 展示。 +- 进入待对账和健康检查统计。 + +如果 Worker 在终态 callback 触发前被强制终止,本地无法生成 final 或 unknown 事件。2.0 不承诺仅靠 callback 枚举这类硬崩溃调用;它们只能通过供应商 usage 对账或额外的 start 事件审计发现。第一阶段为降低模型调用路径影响,不持久化 start 事件。 + +### 3.5 原始观测与权威投影分离 + +Event Inbox 保存收到的 `callback_final:1` usage 观测;Usage Projector 为每个首次 accepted 的模型调用创建权威 Token 投影。v1 没有 revision 覆盖、跨来源优先级或 `disputed` 状态。阶段三若增加供应商对账,必须通过新 schema/migration 保留对账前后的原始事实,不能静默删除或改写历史观测。 + +## 4. 当前进程边界 + +EvoScientist-WebUI 与 EvoScientist 后端由同一条启动命令管理时,仍然是两个独立进程: + +```text +Browser + -> EvoScientist-WebUI Next.js server + -> LangGraph/EvoScientist Python process + -> LLM Provider +``` + +WebUI 的 `useStream()` 只能观察图运行和 SSE。真正的模型客户端由 EvoScientist 的 `get_chat_model()` 创建,异步子代理和 Memory Worker 也可能在独立的 LangGraph worker 中运行。 + +因此: + +- WebUI 可以创建 `turn_id` 和回合关联上下文。 +- WebUI 不能直接运行捕获 Python 模型调用的 callback。 +- callback 必须在实际模型进程内运行。 +- callback 只负责采集和上报,统计业务仍归 EvoScientist-WebUI。 + +本文将模型进程内的部分称为 `UsageCaptureCallback`,将 WebUI 端接口称为 `Usage Collector`,避免把两者都称为 callback。 + +### 4.1 第一阶段支持的部署模式 + +2.0 第一阶段只保证以下拓扑: + +```text +EvoSci 集成启动 +WebUI Next.js server 与 EvoScientist backend 同机 +Collector 使用 loopback 地址 +``` + +独立 WebUI 连接远端 backend 时,远端 backend 无法访问用户机器上的 `127.0.0.1` Collector,因此第一阶段明确显示“Token 统计不可用”,不能显示为零。 + +远程模式后续通过 backend 可访问的 HTTPS Collector URL 支持,并要求 TLS、sink token、明确的网络超时和重试策略。 + +### 4.2 部署和工作区身份 + +每条事件必须包含: + +```text +deployment_id +workspace_id +``` + +- `deployment_id` 是 EvoScientist 安装或 backend 数据目录的稳定 UUIDv4,使用小写 canonical 文本。首次生成时通过 `O_CREAT|O_EXCL` 原子写入 `/deployment-id` 并 fsync 文件和父目录;并发 launcher 必须读取胜出的现有值。 +- `workspace_id` 由 launcher 按下述 v1 算法生成一次并注入 backend/WebUI,TypeScript 不得重复实现另一套路径算法。 +- `workspace_dir` 仅用于本机显示和筛选,不能作为数据库身份或幂等键。 + +workspace identity v1 算法固定为: + +```text +path = expanduser(workspace_dir) +path = realpath(path, strict=true) +path = remove_trailing_separator_except_root(path) +path = Unicode_NFC(path) +if Windows: + path = normcase(path) + path = replace_backslash_with_slash(path) +else: + path = preserve_case(path) + +workspace_id = "ws1_" + lowercase_hex( + SHA-256(utf8(deployment_id + NUL + path)) +) +``` + +该算法解析符号链接;同一真实路径的 symlink 获得同一 ID。POSIX 保留大小写,Windows 按 `normcase` 处理盘符、UNC 和大小写。workspace 移动后路径变化,因此视为新工作区。阶段零必须使用纯函数 fixtures 覆盖 POSIX、Windows drive/UNC、大小写、symlink、根目录和 Unicode 组合字符;修改算法必须使用新的 `wsN_` 前缀,不能静默改变 `ws1_`。 + +数据库中的模型调用身份是 `(deployment_id, model_call_id)`,不能假设一个 Collector 永远只连接一个 backend。 + +## 5. 2.0 总体架构 + +```text +┌──────────────────── EvoScientist-WebUI ────────────────────┐ +│ │ +│ Chat │ +│ -> human message id 作为 turn_id │ +│ │ +│ POST /api/usage/events │ +│ -> Event Inbox │ +│ -> Usage Projector │ +│ -> Model Usage Projection │ +│ │ +│ GET /api/usage/summary │ +│ -> WebUI Token 展示 │ +│ │ +└────────────────────────────────────────────────────────────┘ + ^ + | idempotent UsageEvent + | +┌──────────────────────── EvoScientist ──────────────────────┐ +│ │ +│ get_chat_model() │ +│ -> UsageCaptureCallback │ +│ -> collect run_id + metadata + usage │ +│ -> UsageEvent Sink │ +│ │ +│ async subagent launch │ +│ -> forward turn/thread correlation context │ +│ │ +└────────────────────────────────────────────────────────────┘ + | + v + LLM Provider +``` + +## 6. EvoScientist-WebUI 职责 + +### 6.1 回合关联上下文 + +WebUI 直接使用 human message ID 作为 `turn_id`。`turn_id` 只用于把一次用户回合内的主代理、子代理和后台模型调用关联起来,不属于安全身份。 + +run metadata v1 固定为: + +```json +{ + "usage_context_version": 1, + "turn_id": "human-message-uuid" +} +``` + +`thread_id` 已由 LangGraph RunnableConfig 自动进入模型 callback metadata,不要求浏览器重复生成。 + +`@langchain/langgraph-sdk` 的 run metadata 位于 `stream.submit()` 第二个参数的顶层 `metadata`,不是 `config.metadata`,也不属于 `configurable`。新消息固定使用: + +```ts +stream.submit( + { messages: [newMessage] }, + { + metadata: { + usage_context_version: 1, + turn_id: newMessage.id, + }, + config: buildRunConfig(), + // 其他现有 stream options 保持不变 + } +); +``` + +`buildRunConfig()` 继续只负责模型和 LangGraph config,不保存 `turn_id`。阶段零必须用真实 SDK run 验证顶层 submit metadata 同时出现在模型 callback metadata 和 `get_config()["metadata"]`。 + +发送新消息时: + +- 使用新 human message ID 作为 `turn_id`。 +- 按上面的顶层 `metadata` 结构提交。 + +恢复 interrupt 时: + +- 从当前线程状态中找到 active interrupt 之前最后一条真实 HumanMessage,复用其 message ID。 +- summarization marker、异步完成信号等系统注入的 HumanMessage 必须过滤,不能成为 `turn_id`。 +- 如果无法找到对应 HumanMessage,则本次 resume 的 `turn_id=null`,保留 `thread_id` 并记录归属健康告警,不能生成猜测 ID。 +- `resumeInterrupt` 同样在 `stream.submit(null, options)` 的顶层 `metadata` 提交 `{ usage_context_version: 1, turn_id }`,不能只在首次消息时设置。 + + 2.0 不把 `usage_turn_id` 写入 thread metadata。当前 metadata 更新是“读取、合并、整体替换”,与标题、置顶和模型覆盖并发时可能互相覆盖;直接从消息状态恢复既减少一次远程写入,也消除了该统计字段引入的并发风险。若未来确有其他 metadata 写入需求,必须先实现统一的 `mutateThreadMetadata` 串行队列和带校验的重试,不能在各 hook 中自行读改写。 + +### 6.2 Usage Collector + +WebUI Next.js server 提供内部事件入口: + +```text +POST /api/usage/events +POST /api/usage/sources/heartbeat +GET /api/usage/capabilities +GET /api/usage/status +``` + +capabilities 响应示例: + +```json +{ + "collector_instance_id": "stable-webui-collector-uuid", + "supported_schema_versions": [1], + "supported_topology": "same-host-integrated", + "durable_ingest": true +} +``` + +sender 启动时先探测 capabilities: + +- 连接不到 Collector:保留 spool,按退避策略周期性重探测。集成 launcher 先启动 LangGraph、后启动 Next.js,短暂不可达属于正常启动过程。 +- 404:标记当前 WebUI 不支持 Collector,降低探测频率但不删除 spool。 +- schema 不兼容:暂停事件发送,保留 spool,周期性重探测等待兼容版本。 +- Collector 兼容:开始扫描和上报 spool。 +- UI 根据能力状态显示“可用”“后端不支持”或“Collector 不可达”,不能把不可用解释为零用量。 + +兼容 sender 启动后定期发送 heartbeat: + +```json +{ + "deployment_id": "backend-deployment-uuid", + "workspace_id": "stable-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": "2026-07-16T12:00:00Z" +} +``` + +`sender_status` 只允许 `healthy` 或 `degraded`。首次 spool 写入失败或因软上限拒绝事件时,sender 立即在内存中设置 `first_loss_at`,通过 heartbeat 交给 Collector 持久化,并尽力原子写入 `/usage-spool/status.json`。如果磁盘满、目录不可写且 Collector 同时不可达,跨重启持久化无法保证,必须输出高等级日志,不能伪造“统计完整”。UI 只有在目标 deployment 收到兼容且未过期的 heartbeat 后才显示 Token 数值;没有 heartbeat 或状态 degraded 时都不能把空数据库解释为零。 + +Collector 负责: + +1. 以常量时间比较服务端 sink token,并限制请求体大小。 +2. 验证 schema version、固定 `source=callback_final`、必填字段、字符串长度和 Token 非负安全整数范围。 +3. 按 `event_id` 幂等接收,并检测相同 ID 的 payload 冲突。 +4. 在同一数据库事务中写 Event Inbox 并更新 Usage Projection。 +5. 返回明确的 accepted、duplicate、conflict 或 rejected 状态。 + +sink token 不能暴露给浏览器。 + +本方案不实现用户、角色或 workspace 权限控制: + +- `events`、`heartbeat` 和 `capabilities` 从 WebUI cookie-auth proxy 中豁免,在各自 Route Handler 内校验 sink token。sink token 只用于确认服务端传输来源,不表达用户或 workspace 权限。 +- `status`、`summary` 和 `calls` 不增加独立权限层。WebUI 全局认证启用时自然受现有登录保护;全局认证关闭时,能访问该 WebUI 的客户端可以查询全部本地 Token 统计。 +- v1 是同机、单用户部署,不定义数据所有者、角色、workspace ACL 或多租户隔离。远程和多用户模式必须另立安全方案,不能宣称由本方案覆盖。 + +### 6.3 Usage Projector + +Usage Projector 根据 v1 `callback_final:1` 事件的 usage 状态创建每个 `model_call_id` 的权威投影。 + +规则: + +1. 相同 `event_id` 重放是 no-op。 +2. 同一调用只有 `callback_final:1`;重复事件只能是 duplicate 或 conflict,不能累加。 +3. Token detail 是 input/output 的子集,不能重复计入总量。 +4. 单次调用的 `total_tokens` 口径为 `input_tokens + output_tokens`;跨调用汇总使用任意精度整数,不能使用 JavaScript Number 或 SQLite 浮点 `total()`。 +5. 供应商原始 total 单独保存,用于发现口径差异。 +6. 相同 event ID payload 冲突时保留原投影并记录 conflict。 + +### 6.4 查询和展示 + +WebUI 提供: + +```text +GET /api/usage/summary +GET /api/usage/calls +``` + +首期 summary 支持: + +```text +deployment_id +workspace_id +thread_id +turn_id +workspace_dir +provider_profile_id +model +scope +from +to +``` + +返回值必须同时包含已确认用量和未知调用数: + +```json +{ + "deployment_id": "backend-deployment-uuid", + "workspace_id": "stable-workspace-id", + "input_tokens": "120000", + "output_tokens": "18000", + "total_tokens": "138000", + "confirmed_call_count": "23", + "unknown_call_count": "1", + "by_scope": [], + "by_model": [], + "by_provider": [] +} +``` + +UsageEvent 入站 Token 字段使用 JSON number,并受单次安全整数约束。查询 API 的所有 Token 总量和调用计数使用十进制字符串,包含 summary 顶层、`by_scope`、`by_model`、`by_provider` 和 calls 明细,避免 JavaScript Number 精度丢失。WebUI 使用 `BigInt` 解析和格式化,只在显示层转为带分隔符文本,不能转回 Number 做累计。 + +对话输入区只保留当前线程的紧凑 Token 总量;点击后打开明细面板。明细面板提供 `This chat`、`Workspace` 和 `All sources` 三种隔离范围,顶部汇总 Input、Output、Total、confirmed 调用数和 unknown 调用数,下方按模型调用时间倒序列出模型、Provider、Agent/scope、线程/回合、Input、Output、Total 和 confirmed/unknown 状态。calls 每页最多请求 50 条,通过稳定游标显式加载下一页,不能一次把全部历史记录加载到浏览器。unknown 调用显示 `Unknown`,不能显示为 0;默认查询窗口为最近 7 天。桌面使用对齐列,移动端改为单调用分行布局,不能产生横向滚动或文本重叠。 + +## 7. EvoScientist 轻量采集探针 + +### 7.1 注入位置 + +项目内真实模型统一通过 `get_chat_model()` 创建,因此只在模型工厂注入一个 `UsageCaptureCallback`,不在每个 Agent、Worker 或 Middleware 中重复实现统计逻辑。 + +只有 `EVOSCIENTIST_USAGE_TRACKING=true` 且 sink、deployment、workspace 配置完整时才注入 callback。集成 WebUI launcher 和 `EvoSci deploy` 会自动准备这些配置;未启用统计的 CLI 和第三方直接库调用不创建 sender 线程,也不产生 spool I/O。 + +注入时必须保留 Provider 转换前的身份: + +```text +provider_profile_id +provider_revision +provider_adapter +model_alias +upstream_model_id +``` + +自定义 OpenAI-compatible Provider 最终会使用 `openai` adapter,但统计中必须保留原始 WebUI Provider Profile ID。 + +### 7.2 callback 生命周期 + +第一阶段不对每个流式 chunk 写数据库或发送网络请求。 + +```text +on_chat_model_start + -> 在内存中缓存 run_id 对应的 metadata + +on_llm_new_token + -> 仅当 chunk 携带 usage_metadata 时更新内存中的最后 usage + -> 不写文件、不发送网络请求 + +on_llm_end + -> 读取最终 AIMessage.usage_metadata + -> 构造一个 final UsageEvent + -> 交给 UsageEvent Sink + -> 清理内存上下文 + +on_llm_error + -> 如果异常或最后 chunk 含真实 usage,则上报 + -> 否则按配置上报 unknown + -> 清理内存上下文 +``` + +LangChain 自带的 `UsageMetadataCallbackHandler` 也采用 `on_llm_end` 读取最终 usage。2.0 的自定义 callback 增加的是 run 级身份、持久上报和 Provider Profile 信息,不重新实现 Token 估算器。 + +### 7.3 callback 行为约束 + +- callback 错误不能向模型调用抛出。 +- callback 不能修改模型输入、输出或重试策略。 +- callback 不维护累计总数。 +- callback 每次调用最多产生一个终态事件:有真实 usage 时为 `confirmed`,否则为 `unknown`。 +- callback 必须线程安全,支持同一进程内并发模型调用。 + +### 7.4 Agent scope + +scope 使用以下固定优先级,不能由组件名称做自由文本猜测: + +```text +1. 调用点显式 metadata.usage_scope +2. 受契约测试覆盖的 metadata 映射 + - lc_source=summarization -> summarizer + - run_kind=evomemory_* -> memory + - Scheduler / AutoSkills 的固定 run_kind -> scheduler / autoskills + - 已知 subagent graph/run metadata -> sync_subagent / async_subagent +3. 顶层已知 Agent run -> main +4. unattributed +``` + +v1 `scope` 枚举固定为: + +```text +main +sync_subagent +async_subagent +tool_selector +summarizer +memory +scheduler +autoskills +diagnostic +skill_eval +unattributed +``` + +无法准确分类时使用 `unattributed`,但仍记录 Token。scope 分类失败不能导致实际用量丢失。 + +仅靠当前 metadata 不能稳定识别 Tool Selector,因为它复用了主模型。v1 固定使用 selector-only 模型副本: + +- 对 `disable_thinking()` 返回的 `BaseChatModel` 调用 `model_copy()`,合并原 metadata 并覆盖 `usage_scope=tool_selector`,只把该副本传给 `LLMToolSelectorMiddleware`。 +- 原主模型实例不修改;第三方中间件仍获得真实 `BaseChatModel`,其 `with_structured_output().invoke/ainvoke` 会把模型静态 metadata 传给 callback。 +- 异步子代理启动附加 `usage_scope=async_subagent`。 +- Memory Worker 启动附加 `usage_scope=memory`;Scheduler 和 AutoSkills 复用已有固定 `run_kind` 映射,避免修改其执行代码。 +- summarizer 复用框架已有的 `lc_source=summarization`,并用契约测试锁定;若框架升级后该字段消失,再在 summarizer 模型调用点显式补标签。 + +所有正式支持的 Chat Model 必须通过 selector metadata-copy contract test。模型工厂在注入 Usage callback 前预检 `model_copy()` 是否返回真实 `BaseChatModel`: + +- 预检通过:正常注入 callback,并为 selector 创建带 `usage_scope=tool_selector` 的副本。 +- 预检失败或返回 `RunnableBinding`:不因统计抛错,不改变原有 selector 模型路径;跳过该模型的 Usage callback 注入,并通过 heartbeat 报告 `tracking_degraded_reason=selector_model_copy_unsupported`。 +- 降级模型没有事件,不能伪造为 main、tool_selector 或 unknown;UI 必须显示统计可能不完整。 + +这些修改不能改变 prompt、模型参数、Middleware 顺序、retry、fallback 或工具选择结果。`source_agent` 用于说明调用者身份,不能替代 `usage_scope`。 + +## 8. UsageEvent 协议 + +以下 JSON 是可读示例,不是跨语言协议的唯一来源: + +```json +{ + "schema_version": 1, + "event_id": "::callback_final:1", + "event_type": "usage_observed", + "source": "callback_final", + "authority_class": "observed_final", + "revision": 1, + + "deployment_id": "backend-deployment-uuid", + "workspace_id": "stable-workspace-id", + "model_call_id": "langchain-run-id", + "parent_run_id": "optional-parent-run-id", + "provider_request_id": "optional-provider-request-id", + + "thread_id": "langgraph-thread-id", + "source_session_id": "optional-parent-session-id", + "turn_id": "webui-turn-id", + "workspace_dir": "/workspace/path", + "scope": "main", + "source_agent": "EvoScientist", + + "provider_profile_id": "webui-provider-id", + "provider_revision": "provider-config-revision", + "provider_adapter": "openai", + "model_alias": "chat-main", + "upstream_model_id": "gpt-upstream", + + "usage_status": "confirmed", + "input_tokens": 1000, + "output_tokens": 200, + "provider_total_tokens": 1200, + "input_token_details": {}, + "output_token_details": {}, + + "started_at": "2026-07-16T12:00:00Z", + "observed_at": "2026-07-16T12:00:03Z", + "completed_at": "2026-07-16T12:00:03Z" +} +``` + +`event_id` 精确定义为 `deployment_id + ":" + model_call_id + ":" + source + ":" + decimal(revision)`,不包含跨语言 canonical JSON hash,也不通过分隔符反向解析字段。Collector 的应用层 validator 必须按事件字段重新计算并校验 event ID;不匹配时 rejected,不能直接信任 sender 提供的 ID。Collector 在接收端对规范化后的 payload 计算 SHA-256: + +- event ID 和 payload hash 都相同:返回 duplicate。 +- event ID 相同但 payload hash 不同:写入 conflict 记录并返回 conflict。 +- conflict 不能更新权威投影。v1 没有更高 revision,只能由诊断处理 quarantine;不能自动选择任一 payload。 + +事件中禁止包含: + +- API Key +- prompt 全文 +- 模型输出全文 +- 完整请求头 +- Provider Profile 中的 secret +- 未经白名单过滤的原始响应对象 + +### 8.1 必须落盘的契约工件 + +阶段零必须在 EvoScientist-WebUI 中提交: + +```text +docs/schemas/usage-event-v1.schema.json +docs/schemas/usage-api-v1.md +docs/schemas/usage-spool-v1.md +docs/schemas/fixtures/accepted/*.json +docs/schemas/fixtures/rejected/*.json +docs/schemas/fixtures/projection/*.json +docs/schemas/fixtures/spool/*.json +docs/schemas/fixtures/query/*.json +docs/schemas/fixtures/identity/*.json +docs/schemas/fixtures/metadata/*.json +``` + +- JSON Schema 是 UsageEvent 字段、类型、枚举、长度和 nullability 的唯一事实源,使用 `additionalProperties: false`。 +- API 文档固定 endpoint、sink transport authentication、请求/响应 envelope、HTTP 状态、sender 动作、查询限制和版本协商;不定义用户或 workspace 权限。 +- Spool 文档固定 `tmp -> pending -> inflight -> delete/quarantine` 状态机、锁顺序、每个崩溃点的恢复动作和本地文件系统前提。 +- Python Pydantic 模型和 TypeScript validator 必须共同运行同一组 fixtures;任意一端结果不同都阻止合并。 +- Projection fixtures 以事件序列加期望投影表达重复、unknown、非法 revision 拒绝和 payload conflict,不允许 Python 与 TypeScript 各自解释规则。 +- Spool fixtures 和 fault-injection 测试覆盖文件/目录 fsync 前后、claim 前后、HTTP ACK 前后和进程退出后的恢复。 +- Query fixtures 覆盖 BigInt 聚合和十进制字符串;identity fixtures 覆盖 deployment/workspace ID;metadata fixtures 覆盖新消息、resume、异步子代理和 Memory 透传。 +- `schema_version` 是整数版本。sender 从 capabilities 选择双方支持的最高版本;没有交集时停止发送并保留 spool。字段语义、必填性或枚举变化必须发布新版本,不能静默改变 v1。 + +### 8.2 v1 字段和规范化约束 + +v1 冻结以下约束。所有 schema properties 都必须出现在 payload 中,可空字段使用显式 `null`,不能通过省略表达另一种语义: + +| 类别 | 约束 | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 非空控制字段 | `schema_version`、`event_id`、`event_type`、`source`、`authority_class`、`revision` | +| 非空身份字段 | `deployment_id`、`workspace_id`、`model_call_id`、`scope`、`provider_profile_id`、`provider_adapter`、`model_alias`、`upstream_model_id` | +| 可空关联字段 | `parent_run_id`、`provider_request_id`、`thread_id`、`source_session_id`、`turn_id`、`workspace_dir`、`source_agent`、`provider_revision` | +| usage 字段 | `usage_status` 非空;`input_tokens`、`output_tokens`、`provider_total_tokens` 可空;两个 details 字段为对象且无明细时使用 `{}` | +| 时间字段 | `observed_at`、`completed_at` 非空;`started_at` 可空 | +| `event_type` | v1 只允许 `usage_observed` | +| `source` | v1 固定为 `callback_final` | +| `authority_class` | v1 固定为 `observed_final` | +| `usage_status` | `confirmed`、`unknown` | +| Token | `confirmed` 要求 input/output 为 `0..9007199254740991` 的整数,且两者之和仍不超过该上限;provider total 可空;`unknown` 要求三个 Token 数字字段均为 `null` | +| revision | v1 使用 JSON Schema `const: 1`,普通 sender 不维护 revision 状态 | +| 字符串 | 原子 ID 和枚举最多 256 字符,`event_id` 最多 1024 字符,模型字段最多 512 字符,`workspace_dir` 最多 4096 字符;禁止控制字符 | +| 时间 | RFC 3339 UTC;`completed_at` 非空;started 不得晚于 observed/completed | +| 请求体 | 单事件 JSON,UTF-8,最大 256 KiB;v1 不接受批量数组 | + +Schema 使用 `const` 固定 `source=callback_final`、`authority_class=observed_final` 和 `revision=1`。应用层 validator 额外验证 `input_tokens + output_tokens <= 9007199254740991`。`usage_status` 可以是 `confirmed` 或 `unknown`。Gateway、partial、`provider_reconciled`、`provider_only` 或其他 source 一律 rejected。 + +Collector 只对通过 schema 验证的逻辑事件计算 hash。规范化算法固定为 RFC 8785 JSON Canonicalization Scheme,再对 UTF-8 bytes 计算 SHA-256。验证前的原始请求体不写 Inbox。 + +### 8.3 Collector 响应和 sender 动作 + +| HTTP | 响应状态 | sender 动作 | +| --------------- | ------------------------ | --------------------------------------------- | +| 200 | `accepted` / `duplicate` | 删除 inflight 文件 | +| 400 / 422 / 413 | `rejected` | 移入 quarantine,记录原因 | +| 409 | `conflict` | 移入 quarantine,不更新投影 | +| 401 / 403 | `unauthorized` | 保留事件、暂停发送并报告配置错误 | +| 404 | `unsupported` | 保留事件,按不支持 Collector 的低频策略重探测 | +| 426 | `schema_incompatible` | 保留事件,重新协商 capabilities | +| 429 / 5xx | `retryable` | 回 pending,按退避策略重试 | +| 网络失败/超时 | 无 | 回 pending,按退避策略重试 | + +`POST /api/usage/events` 的响应 envelope 至少包含 `status`、`event_id` 和稳定的 `reason_code`;服务端不得在事务提交前返回 200。401/403 和 426 不能把事件移入 quarantine,因为更正 token 或升级版本后仍可成功上报。 + +### 8.4 v1 Projection 合并契约 + +v1 没有 authority/source priority 排序,合并顺序固定为: + +1. 相同 event ID 和 payload hash 是 duplicate。 +2. 相同 event ID、不同 payload hash 进入 event conflict,不能更新投影。 +3. 首次 accepted 事件创建 `model_usage`;此后同一调用只能 duplicate 或 conflict,不存在 revision 覆盖。 +4. `unknown` 不进入 confirmed Token 总量,但计入 unknown 调用数。 + +v1 不存在 `disputed`。未来引入第二个投影来源时必须升级 schema 和数据库,并在事件入库时保存不可变 `projection_policy_version`;不能依赖当前 Provider Profile 或事件到达顺序。 + +## 9. 异步子代理和后台任务 + +### 9.1 同进程调用 + +主代理、同步子代理、Tool Selector 和摘要器通常继承同一个 RunnableConfig。当前 LangGraph 会自动把 `thread_id`、model、node 和 run metadata 传到模型 callback,因此不在每个组件增加统计中间件;仅 Tool Selector 需要补一个调用级 `usage_scope`,summarizer 使用已有且经过测试的 `lc_source` 映射。 + +### 9.2 异步子代理 + +异步子代理运行在独立 LangGraph run 和可能不同的进程中,Python callback 对象和 `parent_run_id` 不会跨 HTTP 自动传播。 + +启动异步 run 时,复用现有 model passthrough 接入点,额外传递: + +```text +turn_id +source_session_id +usage_scope=async_subagent +source_agent +workspace_dir +``` + +每个异步子代理模型调用仍使用自己的 LangChain `run_id`,`source_session_id` 仅用于归属主会话,不能作为幂等主键。 + +### 9.3 Memory、Scheduler 和 AutoSkills + +Memory Worker 已经携带 `run_kind`、`source_session_id`、`source_agent` 和 workspace metadata,但当前 `MemorySourceContext` 没有回合身份。2.0 增加可空的 `turn_id`,只做归属透传: + +```text +MemorySourceContext.turn_id: str | None + <- build_memory_source_context() 从 get_config().metadata.turn_id 读取 + -> worker metadata.turn_id + -> worker configurable.evomemory_source_turn_id + -> UsageCaptureCallback +``` + +由用户回合触发的 Memory Worker 必须继承该回合 ID;脱离用户回合的后台整理保持 `turn_id=null`。这项修改不改变 Memory prompt、存储、调度或执行逻辑。 + +独立 Scheduler 或 AutoSkills 没有用户回合时,可以没有 `turn_id`,但必须保留 workspace、scope、Provider 和模型信息。 + +## 10. UsageEvent 传输 + +### 10.1 配置 + +集成启动模式和 `EvoSci deploy` 由 launcher 注入: + +```text +EVOSCIENTIST_DATA_DIR= +EVOSCIENTIST_USAGE_TRACKING=true +EVOSCIENTIST_USAGE_SINK_URL=http://127.0.0.1:/api/usage/events +EVOSCIENTIST_USAGE_SINK_TOKEN= +EVOSCIENTIST_DEPLOYMENT_ID= +EVOSCIENTIST_WORKSPACE_ID= +EVOSCIENTIST_USAGE_SPOOL_DIR=/usage-spool +``` + +`data_dir` 的解析规则在 Python 和 TypeScript 中必须一致:优先使用绝对路径 `EVOSCIENTIST_DATA_DIR`;未设置时使用当前用户 home 下的 `.evoscientist`。launcher 将解析后的绝对路径注入两个进程,子进程不能各自重新解释 `~`。deployment ID、sink token、spool、Collector ID 和 usage database 都从该目录派生;若单独设置 `EVOSCIENTIST_USAGE_SPOOL_DIR`,该值也必须是绝对路径。 + +usage sink token 是独立的随机服务端密钥,首次生成后保存在 `/usage-sink-token` 并设置仅当前用户可读。它不能复用 Provider Admin Token 或 WebUI 登录密钥,也不携带用户、角色或 workspace 权限。集成 launcher 在启动 backend 和 WebUI 前生成并分别注入两端环境。独立启动时,`EvoSci deploy` 生成 backend 环境,WebUI 的采集接口在没有显式环境变量时按请求读取同一文件,因此两端启动顺序不限;WebUI 尚未启动期间的事件和心跳先进入 durable spool,连接恢复后补发。 + +无法创建 sink 配置时,EvoScientist 维持原行为并显示警告,不能因为 WebUI Token 功能缺失而阻止独立 `EvoSci deploy`、CLI 或第三方 LangGraph 客户端运行。 + +### 10.2 调用路径约束和可靠性 + +第一阶段默认使用 durable spool,不提供仅内存的 standard 模式。callback 不等待 HTTP,只在终态 callback 中同步完成一次小型原子文件写入: + +```text +on_llm_end / on_llm_error + -> event_file_key = SHA-256(event_id) + -> write ...tmp + -> fsync file + -> 用 O_CREAT|O_EXCL 获取短生命周期 .lock + -> atomic replace to spool/pending/.json + -> fsync parent directory where supported + -> release lock + -> return without waiting for Collector + +background sender + -> claim pending event + -> POST Collector + -> accepted/duplicate: delete + -> conflict/rejected: move to quarantine + -> network/5xx: retry with backoff +``` + +spool 默认位于: + +```text +/usage-spool/ + pending/ + inflight/ + quarantine/ +``` + +`usage-spool-v1.md` 是 spool 状态机的唯一事实源;上面的流程图只用于说明。v1 只保证本地文件系统上的原子 create/rename/fsync 语义,不支持把 spool 放在 NFS、SMB、对象存储挂载或其他无法保证相同语义的网络文件系统。workspace 和大文件存储可以远程,但 usage spool 必须本地;无法满足时关闭 Token 统计并在 UI 显示不可用,不能影响模型调用。 + +spool 文件名不能直接使用含冒号的 `event_id`,否则 Windows 无法创建文件。文件名固定使用小写十六进制 `SHA-256(event_id)`;事件正文保留原始 `event_id`,Collector 的幂等判断不依赖文件名。唯一临时名避免多 worker 共用 `.json.tmp`;短锁只保护同一个 event key,不形成全局锁。若 pending/inflight 已有同 event ID 和 payload hash,则丢弃新临时文件;同 event ID payload 不同时移入本地 quarantine。进程启动时清理超过 inflight lease 的 stale lock 和 orphan temp。 + +多个 LangGraph worker 可能共享 spool。sender 通过原子 rename 将 pending 文件声明为 inflight;进程启动时回收超过租约时间的 stale inflight 文件,保证崩溃后能够继续发送。 + +durable spool 只是传输 outbox,不是 Token 权威数据库。权威数据仍在 EvoScientist-WebUI。它只保存白名单 UsageEvent,不保存 prompt、输出或 Provider secret。 + +spool 写入或事件上报失败必须 fail-open,不能把成功模型响应转换成失败,也不能因此触发模型 fallback。spool 写入失败需要高等级日志和健康状态,因为该事件此后无法自动恢复。 + +同步落盘会增加终态 callback 延迟,因此阶段零必须在 macOS、Linux、Windows 的发布参考本地 SSD 上测试 1 KiB 事件和 1/4/16 worker 并发。相对关闭统计的额外延迟门槛为 P95 不超过 20 ms、P99 不超过 100 ms;任何目标平台不通过时,阶段一不能发布,必须重新设计 outbox 写入方式,不能用“文件很小”代替性能证据。 + +### 10.3 复用已有后端 + +集成 launcher 可以在启动 LangGraph 子进程前注入 sink 环境变量。若 WebUI 复用一个已经运行的 EvoScientist backend,该 backend 不会自动获得新环境变量,需要: + +- 使用已配置 sink 的 backend;或 +- 重启 backend;或 +- 后续增加受认证的动态 sink 注册能力。 + +第一阶段不增加动态 sink 注册,避免引入任意 URL 注入和 SSRF 风险。复用 backend 若未配置兼容 Collector,UI 明确显示统计不可用。 + +### 10.4 运行参数默认值 + +第一版使用以下默认值,并允许通过同名配置覆盖。配置名、单位和上下界必须写入 `usage-api-v1.md`,Python 与 TypeScript 不得各自保留另一套常量。 + +| 配置键 | 默认值 | 行为 | +| --------------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------- | +| `EVOSCIENTIST_USAGE_HEARTBEAT_INTERVAL_SECONDS` | 15 | sender 正常运行时上报 | +| `EVOSCIENTIST_USAGE_HEARTBEAT_TTL_SECONDS` | 45 | 超时后 UI 标记 backend 离线,不显示零用量 | +| `EVOSCIENTIST_USAGE_HTTP_CONNECT_TIMEOUT_SECONDS` / `EVOSCIENTIST_USAGE_HTTP_TIMEOUT_SECONDS` | 1 / 3 | 超时后事件回 pending | +| `EVOSCIENTIST_USAGE_RETRY_INITIAL_SECONDS` / `EVOSCIENTIST_USAGE_RETRY_MAX_SECONDS` | 1 / 60 | 2 倍增长、20% jitter,适用于网络、429 和 5xx | +| `EVOSCIENTIST_USAGE_UNSUPPORTED_REPROBE_SECONDS` | 300 | capabilities 404 后保留 spool | +| `EVOSCIENTIST_USAGE_SCHEMA_REPROBE_SECONDS` | 60 | schema 不兼容时保留 spool | +| `EVOSCIENTIST_USAGE_INFLIGHT_LEASE_SECONDS` | 120 | 超时文件由 sender 原子回收到 pending | +| `EVOSCIENTIST_USAGE_MAX_EVENT_BYTES` | 262144 | 超限进入 quarantine | +| `EVOSCIENTIST_USAGE_SPOOL_MAX_FILES` / `EVOSCIENTIST_USAGE_SPOOL_MAX_BYTES` | 100000 / 1073741824 | 先到者为软上限;不自动删除未确认事件 | +| `EVOSCIENTIST_USAGE_QUARANTINE_RETENTION_DAYS` | 90 | 到期清理前输出计数和原因;pending/inflight 不按时间淘汰 | +| `EVOSCIENTIST_USAGE_INBOX_RETENTION_DAYS` | 90 | Projection 长期保留 | +| `EVOSCIENTIST_USAGE_QUERY_DEFAULT_DAYS` / `EVOSCIENTIST_USAGE_QUERY_MAX_DAYS` | 7 / 90 | 更大范围返回 422 | +| `EVOSCIENTIST_USAGE_CALLS_PAGE_SIZE` / `EVOSCIENTIST_USAGE_CALLS_MAX_PAGE_SIZE` | 50 / 500 | 使用稳定游标,不允许无界查询 | + +spool 达到软上限意味着统计链路已不完整,`/api/usage/status` 必须显示 `degraded` 和首次丢失时间。不能为了继续写入而静默删除最旧 pending 事件。 + +## 11. Provider usage 策略 + +### 11.1 第一阶段不改变请求参数 + +原生 OpenAI、Anthropic 和部分 Provider 已经返回标准 `usage_metadata`。自定义 OpenAI-compatible 网关对 `stream_options.include_usage` 的支持不一致。 + +为避免 Token 统计改变模型行为,第一阶段: + +- 不对所有 Provider 强制设置 `stream_usage=True`。 +- 不因缺少 usage 自动重试模型。 +- 不从 prompt 或文本长度估算 Token。 +- 缺少真实 usage 时记录 unknown。 + +### 11.2 后续 Provider 能力配置 + +验证具体网关后可增加: + +```text +stream_usage_mode = auto | required | disabled +``` + +该配置属于 Provider Profile 能力,不应根据 `provider_adapter=openai` 一刀切。 + +### 11.3 阶段零 Provider 兼容性矩阵 + +实现 callback 前,必须对项目锁定的 SDK/LangChain 版本生成真实或可回放的响应 fixtures。矩阵记录“字段实际出现在哪里”,不能只根据 Provider 名称假设: + +| Provider 类型 | 非流式 usage | 流式 usage | request ID | 阶段一结论 | +| ---------------------------------------------- | --------------------------------------------------- | --------------------------------------- | ---------------------------------- | ------------------------------- | +| 原生 OpenAI | `AIMessage.usage_metadata` 与原始 response metadata | 验证默认 `stream_usage` 和最后 chunk | 验证 response metadata/header 映射 | confirmed 或明确 unknown | +| 原生 Anthropic | `AIMessage.usage_metadata` | 验证最后 chunk/最终 message | 验证 response metadata | confirmed 或明确 unknown | +| 自定义 OpenAI-compatible,支持 include_usage | 用兼容网关 fixture 验证 | 显式验证 `stream_options.include_usage` | 验证网关字段 | Provider Profile 标记能力后启用 | +| 自定义 OpenAI-compatible,不支持 include_usage | 验证非流式结果 | 不改变请求,预期可为 unknown | 有则保存 | unknown,不重试、不估算 | +| fallback/retry | 每个 LangChain run 独立 fixture | 同左 | 保存每次可见 request ID | 验证调用数而非只验 Token 总和 | + +每一行至少覆盖成功、流式、缺 usage、模型错误四类 fixture,并记录 SDK 版本。Provider 兼容性失败不阻止统计框架上线,但必须把相应调用稳定标记为 unknown,不能误报 confirmed。 + +### 11.4 retry 和 fallback + +- 每个 LangChain fallback 调用有独立 `model_call_id`,分别计量。 +- LangChain 外层 retry 如果产生新的 run_id,分别计量。 +- Provider SDK 内部 HTTP retry 可能仍共享一个 run_id,本地 callback 无法判断前一次请求是否产生了额外用量。 +- SDK 内部 retry 的额外用量只能通过供应商 usage 对账发现。 + +## 12. WebUI 数据模型 + +2.0 将 Event Inbox 和权威投影保存在 EvoScientist-WebUI 服务端 SQLite。运行时驱动确定为 `better-sqlite3`,作为 npm runtime dependency 安装,不把构建机生成的 native binary 固化进发布包。 + +Next.js 配置将 `better-sqlite3` 列入 `serverExternalPackages`,使 `npx` 安装时在目标机器为当前 Node ABI 和平台安装依赖。standalone 组装脚本必须验证运行时能从 package 根目录解析该依赖。 + +首期发布门槛包括 macOS、Linux、Windows 的 `npm pack -> npx/install -> start:dist` 测试。任何目标平台无法安装或加载 SQLite native module 时,不发布该版本。 + +数据库位于: + +```text +/webui/usage.db +``` + +不能写入 npm package 的 `dist/` 目录,也不复用 EvoScientist `sessions.db`。 + +数据库初始化使用: + +```sql +PRAGMA journal_mode=WAL; +PRAGMA synchronous=FULL; +PRAGMA busy_timeout=5000; +PRAGMA foreign_keys=ON; +``` + +Collector 只有在事务提交完成后才返回 accepted/duplicate,sender 收到确认后才删除 spool。`synchronous=FULL` 用于避免 Collector 已确认、spool 已删除后因主机异常丢失最近提交。 + +- 使用 `PRAGMA user_version` 管理 migration。 +- `collector_instance_id` 首次启动时生成,并持久化在 WebUI 数据目录;数据库清空不会静默复用其他 Collector 的身份。 +- 通过 `globalThis` 维护进程级单例连接,避免 Next.js 开发热更新重复打开连接。 +- 所有写入使用短事务。 +- API 查询必须分页并设置时间范围上限。 +- 定义 Inbox 保留期和数据库备份方式;默认保留原始事件 90 天,权威投影长期保留。 + +`better-sqlite3` 连接启用 safe integers。汇总不能使用 SQLite `total()` 或让结果进入 JavaScript Number;WebUI 注册确定性的 `decimal_sum` aggregate,以 `BigInt` 累加 SQLite INTEGER 并返回十进制字符串。`COUNT(*)` 也以 BigInt 读取并序列化为十进制字符串。数据库、API 和 UI 的共享 fixtures 必须覆盖汇总值超过 `9007199254740991` 的情况。 + +### 12.1 usage_sources + +```sql +CREATE TABLE usage_sources ( + deployment_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + emitter_version TEXT NOT NULL, + schema_version INTEGER NOT NULL, + sender_status TEXT NOT NULL CHECK (sender_status IN ('healthy', 'degraded')), + spool_pending INTEGER NOT NULL DEFAULT 0, + spool_inflight INTEGER NOT NULL DEFAULT 0, + spool_quarantined INTEGER NOT NULL DEFAULT 0, + spool_bytes INTEGER NOT NULL DEFAULT 0, + first_loss_at TEXT, + tracking_degraded_reason TEXT, + last_error_code TEXT, + last_seen_at TEXT NOT NULL, + + PRIMARY KEY (deployment_id, workspace_id) +); +``` + +`usage_sources` 由 heartbeat 更新,供 `/api/usage/status` 判断目标 backend 是否兼容和在线。`first_loss_at` 使用 earliest-non-null 合并:已存非空值不能被后续 null 或更晚时间覆盖;一旦存在,Collector 的完整性状态保持 degraded。普通 heartbeat 不能重置该状态,v1 只允许通过清空对应统计数据恢复“完整”语义。 + +### 12.2 usage_event_inbox + +```sql +CREATE TABLE usage_event_inbox ( + event_id TEXT PRIMARY KEY, + deployment_id TEXT NOT NULL, + model_call_id TEXT NOT NULL, + source TEXT NOT NULL CHECK (source = 'callback_final'), + authority_class TEXT NOT NULL CHECK (authority_class = 'observed_final'), + revision INTEGER NOT NULL CHECK (revision = 1), + payload_hash TEXT NOT NULL, + payload_json TEXT NOT NULL, + received_at TEXT NOT NULL +); +``` + +Inbox 用于网络重试幂等和最小审计,不承担聚合查询。 + +Inbox 按保留期清理后仍必须保留最小幂等墓碑;否则 90 天后的 sender 重放会因 +`model_usage` 主键冲突而无法判定 duplicate。数据库 migration v2 增加: + +```sql +CREATE TABLE usage_event_idempotency ( + event_id TEXT PRIMARY KEY, + payload_hash TEXT NOT NULL, + retained_at TEXT NOT NULL +); +``` + +墓碑不包含 prompt、输出或完整 payload,长期保留并继续支持 duplicate/conflict 判定。 + +### 12.3 usage_event_conflicts + +```sql +CREATE TABLE usage_event_conflicts ( + conflict_id TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + stored_payload_hash TEXT NOT NULL, + received_payload_hash TEXT NOT NULL, + received_payload_json TEXT NOT NULL, + received_at TEXT NOT NULL, + + UNIQUE (event_id, received_payload_hash) +); +``` + +相同 `event_id` 但 payload 不同时写入 conflict,不能更新权威投影。`conflict_id` 固定为 `SHA-256(event_id + NUL + received_payload_hash)`;即使 409 响应在网络中丢失,同一冲突重放也不会产生重复记录。 + +### 12.4 model_usage + +```sql +CREATE TABLE model_usage ( + deployment_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + model_call_id TEXT NOT NULL, + source TEXT NOT NULL CHECK (source = 'callback_final'), + authority_class TEXT NOT NULL CHECK (authority_class = 'observed_final'), + revision INTEGER NOT NULL CHECK (revision = 1), + + parent_run_id TEXT, + provider_request_id TEXT, + thread_id TEXT, + source_session_id TEXT, + turn_id TEXT, + workspace_dir TEXT, + scope TEXT NOT NULL DEFAULT 'unattributed', + source_agent TEXT, + + provider_profile_id TEXT NOT NULL, + provider_revision TEXT, + provider_adapter TEXT NOT NULL, + model_alias TEXT NOT NULL, + upstream_model_id TEXT NOT NULL, + + usage_status TEXT NOT NULL CHECK (usage_status IN ('confirmed', 'unknown')), + input_tokens INTEGER + CHECK (input_tokens IS NULL OR input_tokens BETWEEN 0 AND 9007199254740991), + output_tokens INTEGER + CHECK (output_tokens IS NULL OR output_tokens BETWEEN 0 AND 9007199254740991), + provider_total_tokens INTEGER + CHECK (provider_total_tokens IS NULL OR provider_total_tokens BETWEEN 0 AND 9007199254740991), + input_details_json TEXT NOT NULL, + output_details_json TEXT NOT NULL, + + started_at TEXT, + observed_at TEXT NOT NULL, + completed_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + + PRIMARY KEY (deployment_id, model_call_id), + CHECK ( + (usage_status = 'confirmed' AND input_tokens IS NOT NULL AND output_tokens IS NOT NULL) + OR + (usage_status = 'unknown' AND input_tokens IS NULL AND output_tokens IS NULL + AND provider_total_tokens IS NULL) + ), + CHECK ( + usage_status = 'unknown' + OR input_tokens + output_tokens <= 9007199254740991 + ) +); + +CREATE INDEX model_usage_thread_idx + ON model_usage(deployment_id, thread_id, completed_at); + +CREATE INDEX model_usage_source_session_idx + ON model_usage(deployment_id, source_session_id, completed_at); + +CREATE INDEX model_usage_turn_idx + ON model_usage(deployment_id, turn_id, completed_at); + +CREATE INDEX model_usage_provider_idx + ON model_usage(deployment_id, provider_profile_id, upstream_model_id, completed_at); +``` + +处理事件时使用一个短事务: + +```text +BEGIN + 查询 event_id 是否存在 + 如果不存在:插入 usage_event_inbox + 如果存在且 payload_hash 相同:返回 duplicate + 如果存在且 payload_hash 不同:插入 usage_event_conflicts,返回 conflict + 对首次 accepted 事件:INSERT model_usage +COMMIT +``` + +`/api/usage/calls` 的稳定游标由 `completed_at + deployment_id + model_call_id` 组成,同一时间戳或跨 deployment 时不能漏项或重复。 + +## 13. 供应商 usage 对账 + +供应商对账不属于 v1,也不能调用 `/api/usage/events`。阶段三若 Provider 或统一网关提供逐请求用量接口,必须先发布新 schema version 和数据库 migration,再由 EvoScientist-WebUI 内部任务执行: + +1. 按 Provider Profile 和时间窗口获取供应商 usage 记录。 +2. 使用 `provider_request_id` 匹配本地调用。 +3. 生成内部 `provider_reconciled` 事实,不经过 callback sink token 或外部 Collector route。 +4. 更新同一个 `model_call_id` 的权威投影。 +5. 记录对账前后的 Token 差异。 + +新版本必须保存不可变 `projection_policy_version` 和对账任务身份。普通 sender 永远不能声明 `provider_reconciled`、`provider_only` 或其他高权威来源;Provider Profile 后续修改不能改变已经采用的历史策略。 + +若供应商有记录而本地没有对应调用,使用供应商 request ID 创建合成 model call,并标记 `source=provider_only`。其 `model_call_id` 固定为 `provider-only:` 加 `SHA-256(deployment_id + NUL + provider_profile_id + NUL + provider_request_id)`;`thread_id`、`turn_id` 和 `source_session_id` 为 null,`scope=unattributed`。供应商记录没有逐请求 ID 时不能创建合成调用。 + +如果供应商只提供时间窗口汇总,系统只能保存 Provider 级差异记录,不能伪造单次调用明细。 + +截至 2026-07-16,当前接入的 OpenAI 和 Anthropic 官方组织 Usage API 均只提供时间桶聚合,不返回可用于逐调用匹配的 request ID。因此当前实现通过数据库 migration 3 保存内部 `aggregate-report-v1` 观测,并按 deployment、Provider、模型和时间窗口生成任意精度 Token/请求数差异报告;该报告固定 `projection_updated=false`,不修改 `model_usage`,也不创建 `provider_only` 调用。能力判断和官方接口依据见 `docs/schemas/provider-reconciliation-capabilities.md`。未来出现逐请求接口时,仍必须按上面的新 schema 和不可变投影策略发布,不能复用聚合报告静默改写历史。 + +## 14. 故障处理 + +| 场景 | 行为 | +| --------------------------------------- | --------------------------------------------------------------------------- | +| callback 重复上报 | Collector 按 event_id 幂等忽略 | +| final 重复到达 | 相同 payload 为 duplicate;不同 payload 为 conflict,均不累加 | +| sender 提交 partial/Gateway/对账 source | v1 schema rejected 并移入 quarantine | +| WebUI Collector 暂时不可用 | 事件保留在 spool,后台退避重试 | +| callback 自身异常 | 记录日志,不影响模型响应 | +| Provider 不返回 usage | 标记 unknown,不估算、不计入已确认总量 | +| Worker 在终态 callback 前硬退出 | 本地无事件;等待供应商 usage 对账,不伪造 unknown | +| 异步子代理独立进程 | 透传回合关联上下文,使用自己的 run_id | +| SDK 内部 retry 无法区分 | 等待供应商对账 | +| WebUI 数据库写入失败 | Collector 返回失败,sender 重试 | +| sink token 错误或 schema 不兼容 | 保留 spool 并暂停发送,修正配置/升级后恢复 | +| spool 达软上限或写入失败 | 模型调用继续;内存标记 degraded,heartbeat/状态文件尽力持久化 first_loss_at | +| 同 event ID 出现不同 payload | 写 conflict,保持原投影 | +| 供应商对账修正 | 更新 Token 投影并保留差异记录 | + +## 15. 最小修改范围 + +### 15.1 EvoScientist-WebUI + +建议新增: + +```text +src/app/api/usage/events/route.ts +src/app/api/usage/capabilities/route.ts +src/app/api/usage/sources/heartbeat/route.ts +src/app/api/usage/status/route.ts +src/app/api/usage/summary/route.ts +src/app/api/usage/calls/route.ts +src/lib/server/usageStore.ts + 启用 safe integers,注册 decimal_sum BigInt aggregate +src/lib/server/usageProjector.ts +src/lib/server/usageMigrations.ts +src/lib/usageTypes.ts +src/app/hooks/useUsage.ts +docs/schemas/usage-event-v1.schema.json +docs/schemas/usage-api-v1.md +docs/schemas/usage-spool-v1.md +docs/schemas/fixtures/ +``` + +建议修改: + +```text +src/app/hooks/useChat.ts + 新消息和 resume 都通过 submit options 顶层 metadata 提交 turn_id,不写 thread metadata + +src/proxy.ts + cookie-auth proxy 豁免 events/heartbeat/capabilities;各 Route Handler 自行校验 sink token + +ChatInterface 或独立 Usage 面板 + 查询和展示 confirmed/unknown 用量 + +package.json / Next config / standalone build + externalize better-sqlite3,包含 migration 并验证目标平台运行时解析 +``` + +### 15.2 EvoScientist + +只修改以下接入点: + +```text +新增 usage/callback.py、usage/sink.py 和 usage/spool.py + 采集、原子 spool 并上报标准 UsageEvent + +llm/models.py + model_copy 预检通过后注入 callback 和静态 Provider metadata;失败只降级统计 + +llm/patches.py + 异步子代理透传回合关联上下文和 source_session_id + +middleware/tool_selector.py + 用 model_copy 创建 selector-only metadata 副本,不修改主模型 + +memory/source_context.py、memory/launch.py + 从 get_config().metadata 读取可空 turn_id 并透传给 Memory Worker + +deploy/webui.py + 原子生成 deployment_id,按 ws1 算法生成 workspace_id,并注入 sink URL/token +``` + +明确不修改: + +- 主 Agent 构建逻辑 +- Agent Middleware 顺序 +- Tool Selector 的 prompt、模型参数和选择行为 +- Memory Worker 的 prompt、存储、调度和执行逻辑 +- Scheduler 核心逻辑 +- `stream/events.py` +- `stream/state.py` +- checkpoint schema +- Provider 请求和 retry 行为 + +## 16. 分阶段实施 + +### 阶段零:实施契约冻结 + +1. 提交 JSON Schema、API 契约、spool 状态机和 accepted/rejected/projection/spool 共享 fixtures。 +2. Python Pydantic 与 TypeScript validator 对共享 fixtures 取得完全一致的结果。 +3. 用真实 callback metadata fixture 验证 main、subagent、Tool Selector selector-only model copy、summarizer、Memory、Scheduler 和 AutoSkills 的 scope 映射。 +4. 完成 Provider usage 兼容性矩阵,记录锁定 SDK 版本、usage 与 request ID 的真实位置。 +5. 完成 macOS、Linux、Windows 的 `better-sqlite3` 安装、migration 和 standalone 启动 spike。 +6. 验证 data_dir、deployment ID、sink token、spool 和 database 在 Python/TypeScript 两端解析到相同绝对路径。 +7. 在三个平台完成 spool 每个崩溃点的 fault injection、多 worker claim/recovery 和本地文件系统语义测试。 +8. 完成同步 spool 的 1/4/16 worker 延迟基准并满足 P95/P99 门槛。 +9. 验证 ingest route 绕过 cookie proxy 后仍强制 sink token,查询 route 不增加 workspace ACL。 +10. 用真实 `useStream.submit` 验证顶层 metadata 在 callback 和 `get_config()` 中一致,新消息/resume 都覆盖。 +11. 用跨平台 workspace fixtures 验证 `ws1_` 算法和 deployment ID 并发创建。 +12. 验证 `revision=1` const、单次 Token 和安全、BigInt 聚合及十进制字符串 API。 + +阶段零是编码门禁。schema、API 状态矩阵、Projection 合并规则、spool 状态机、scope 标签和 Memory turn 归属未通过共享测试前,不并行实现 sender 与 Collector;阶段零通过后,各模块可以按契约独立开发。 + +### 阶段一:Token 事实链路 + +1. WebUI 接入 `better-sqlite3`、migration 和跨平台 standalone 打包测试。 +2. WebUI 实现 capabilities、heartbeat/status、Collector、Inbox、conflict、Projection 和 summary API。 +3. EvoScientist 实现轻量 callback、durable spool、capability 探测、heartbeat 和 HTTP sender。 +4. 模型工厂完成 selector model-copy 预检后注入 callback,保留 Provider Profile 身份;失败时 Agent 正常运行且状态 degraded。 +5. WebUI 使用 human message ID 创建 turn_id,interrupt resume 从消息状态恢复。 +6. 增加 Tool Selector 显式 scope、异步子代理上下文和 Memory 可空 turn_id 透传。 +7. UI 展示线程和回合级 confirmed Token、unknown 数量及 Collector 能力状态。 +8. 验证 backend 和 WebUI 进程重启后的 spool 重放与幂等。 + +阶段一不强制 stream usage,也不实现供应商对账。 + +### 阶段二:异步归属和运行健康 + +1. 增加 Collector 健康状态、spool 积压、首次丢失时间和 quarantine 监控。 +2. 扩大多 worker 压力测试,验证软上限和长期 stale inflight 回收。 +3. 增加 Inbox/quarantine 保留任务和备份恢复演练。 + +### 阶段三:供应商 usage 能力门禁和差异报告 + +1. 记录当前 Provider Usage API 的字段和粒度能力;没有 request ID 时禁止逐调用 reconciliation。 +2. 通过数据库 migration 3 保存内部聚合 usage 观测和固定 `aggregate-report-v1` policy。 +3. 生成精确的 Provider 时间窗差异报告,保持 `projection_updated=false`。 +4. 外部 `/api/usage/events` 继续拒绝 reconciliation source;未来逐请求接口必须发布新 schema version 后才能更新权威投影。 + +## 17. 测试和验收 + +### 17.1 callback + +- 能从最终 `AIMessage.usage_metadata` 提取 input/output Token。 +- 流式 chunk 携带 usage 后发生 error 时,能够从内存缓存生成已确认 usage 事件。 +- 同一进程并发调用不会串联 run metadata。 +- callback 异常不会改变模型返回结果。 +- 自定义 Provider 转为 OpenAI adapter 后仍保留 Provider Profile ID。 +- OpenAI、Anthropic 和两类 custom-compatible fixture 覆盖非流式、流式、缺 usage 和模型错误。 +- 没有 usage 时产生 unknown,而不是估算值。 +- Worker 在终态 callback 前硬退出时不伪造 unknown。 +- final/unknown 事件先原子写入 spool,不等待 HTTP。 +- 进程重启后可以重放未确认事件。 +- 多 worker 同时写相同 event key 时不会产生半文件、跨 payload 覆盖或 Windows 非法文件名。 +- stale lock、orphan temp 和 stale inflight 能在 lease 后恢复或清理。 +- spool 达软上限后 callback 仍 fail-open;heartbeat 可达时 Collector 持久化 `first_loss_at`,状态文件不可写时明确记录持久化边界。 +- 同步 spool 在 macOS、Linux、Windows 上满足 P95 20 ms、P99 100 ms 的额外延迟门槛。 +- `on_llm_end` 没有 metadata 时能按 run_id 取回 start 阶段缓存,结束后不泄漏缓存。 +- scope 优先级严格遵守显式 `usage_scope`、受测映射、main、unattributed 的顺序。 +- Tool Selector 使用 metadata model copy 后选择结果与未加统计标签时一致,主模型 metadata 不被污染。 +- selector model-copy 预检失败时不抛出、不改变工具选择路径、不产生错误归类事件,并上报 degraded 原因。 + +### 17.2 Collector 和 Projector + +- 同一个 event 上报两次只处理一次。 +- partial、Gateway、provider reconciled 和 provider only source 全部被 v1 schema 拒绝。 +- `revision != 1` 被 v1 schema 和数据库约束拒绝。 +- 相同 event ID、不同 payload 会进入 conflict,且不更新投影。 +- Token detail 不会重复加到总量。 +- 数据库事务失败时不会只写 Inbox 或只写 Projection。 +- `(deployment_id, model_call_id)` 能隔离不同 backend 的调用。 +- Python 和 TypeScript 对全部 accepted/rejected fixtures 结论一致。 +- 401/403、404、409、426、429、5xx 和超时分别执行契约规定的保留、quarantine 或重试动作。 +- 首次 accepted 事件的关联字段完整写入投影;duplicate 不修改,conflict 不静默覆盖。 +- event ID 字段与重算结果不一致时 rejected;payload hash 使用固定 JCS 结果。 +- 单次 input/output 之和超过安全整数时 rejected;聚合超过安全整数时仍精确返回十进制字符串。 + +### 17.3 主代理和后台任务 + +- 主代理每次真实模型调用单独记录。 +- 同步子代理继承 thread 和 turn。 +- 异步子代理使用独立 model_call_id,同时关联 source session。 +- fallback 的每个实际尝试分别记录。 +- Tool Selector 和 summarizer 分别稳定归类,不混入 main。 +- 用户回合触发的 Memory Worker 继承 turn;后台 Memory 的 turn 为 null。 +- Scheduler 和 AutoSkills 使用固定 scope;未识别调用明确归类为 unattributed。 + +### 17.4 WebUI + +- 新消息生成新的 turn_id。 +- 新消息和 interrupt resume 都使用 submit options 顶层 metadata;`config.metadata` 和 `configurable` 中不保存 turn_id。 +- 页面刷新后的 interrupt resume 能从最后一条真实 HumanMessage 复用原 turn_id。 +- summarization marker 和异步完成信号不会被误选为 turn_id。 +- Token 归属不写 thread metadata,与标题、置顶和模型覆盖并发时不会造成字段丢失。 +- 线程汇总包含关联的异步子代理调用。 +- unknown 数量可见,不显示为零 Token。 +- 浏览器无法读取 sink token。 +- ingest routes 不要求 WebUI cookie,但缺少/错误 sink token 时拒绝;查询 routes 不增加用户、角色或 workspace ACL。 +- WebUI 全局认证开启时查询接口受现有登录保护,关闭时不额外增加 Token 统计权限层。 +- Collector 不可达或 schema 不兼容时显示明确状态,不显示为零。 +- 旧 backend 没有兼容 heartbeat 时显示“未启用或不支持”,不显示为零。 +- heartbeat 超过 45 秒显示离线,sender degraded 时显示统计可能不完整和首次丢失时间。 +- summary 超过 90 天返回 422,calls 稳定游标在相同时间戳下不漏项、不重项。 +- summary、calls 和所有分组的 Token/计数字段是十进制字符串,UI 使用 BigInt 格式化。 +- Token 明细面板支持线程、workspace 和全部来源三种范围;calls 使用稳定游标逐页加载,unknown 行明确显示 `Unknown`。 +- Token 明细面板在桌面和 390px 移动视口中无横向溢出、控件遮挡或 Token 列错位。 +- `npm pack` 后的 standalone 在 macOS、Linux、Windows 可以加载 SQLite 驱动。 +- 自定义 data_dir 下的 deployment ID、token、spool 和 database 路径在两个进程中一致。 + +## 18. 验收标准 + +1. 一次包含多次主代理、子代理和 fallback 的回合,记录数等于真实模型调用数。 +2. 相同 UsageEvent 重放不会增加汇总 Token。 +3. EvoScientist-WebUI 重启后 Token 投影保持一致。 +4. Collector 或 callback 故障不影响 Agent 正常回答。 +5. 已触发终态 callback 但未返回真实 usage 的调用可以枚举为 unknown;终态前硬崩溃不在该承诺内。 +6. 异步子代理 Token 可以归属到启动它的主会话。 +7. Tool Selector、Memory 和异步子代理只增加统计 metadata/可空上下文,不改变模型与 Agent 行为。 +8. backend 退出前已写入 spool 的事件在重启后能够上报,且不重复累计。 +9. 一个 Collector 接收多个 deployment 的事件时不会混合主键或汇总归属。 +10. Python sender 与 TypeScript Collector 通过同一 schema 和 fixtures,协议不存在双重定义。 +11. unknown 调用可枚举且不进入 confirmed Token 总量;v1 不存在 disputed。 +12. Provider usage 缺失或格式不兼容时稳定降级为 unknown,不触发额外模型调用。 +13. v1 外部 Collector 只能接受 callback final,不能提交 Gateway 或供应商对账权威事实。 +14. Token 统计不新增用户、角色、workspace ACL 或多租户权限模型。 +15. 新消息和 resume 的 turn_id 均能从顶层 submit metadata 到达 callback、异步子代理和 Memory Worker。 +16. v1 revision 固定为 1,所有 Token 聚合超过 JavaScript 安全整数后仍保持精确。 +17. 同一 workspace 的真实路径/symlink 在 ws1 规则下身份一致,跨重启不会漂移。 + +## 19. 最终决策 + +2.0 采用: + +> EvoScientist-WebUI 管理协议、回合关联、事件接收、Token 权威投影和查询展示;EvoScientist 只运行轻量 UsageCaptureCallback,并透传 scope、异步和 Memory 归属上下文。 + +不采用: + +- 从聊天消息或 SSE 文本累计 Token。 +- 每个 Agent 各自实现统计中间件。 +- 将 partial、final 和 reconciliation 相加。 +- 将 unknown 当作零或估算值。 +- callback 上报失败时让模型调用失败。 +- 为实现零代码修改而强制所有 Provider 迁移到 WebUI LLM Gateway。 +- 在 v1 接受 partial、Gateway 或供应商对账 source。 +- 为 Token 统计新增用户、角色或 workspace 权限控制。 + +该边界兼顾了完整性和低侵入性:复杂业务集中在 EvoScientist-WebUI,EvoScientist 只承担无法从外部替代的模型调用观测与少量 metadata 透传职责。实施必须先通过阶段零契约门禁,再进入并行编码。 diff --git a/docs/webui-approval-modes.md b/docs/webui-approval-modes.md new file mode 100644 index 0000000..f2ceb0b --- /dev/null +++ b/docs/webui-approval-modes.md @@ -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(() => + 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: +2. ask-user: +3. ask-user: +``` + +规则: + +- 检测到 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。 diff --git a/next.config.ts b/next.config.ts index eed90f5..0534584 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,6 +4,13 @@ const nextConfig: NextConfig = { // Self-contained server bundle for the npm package (the bin launcher runs // dist/server.js). Needed because /api/skills is a server route. 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; diff --git a/package-lock.json b/package-lock.json index 8a463b0..c72dd9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,8 +18,11 @@ "@radix-ui/react-slot": "^1.2.4", "@types/react-syntax-highlighter": "^15.5.13", "@types/uuid": "^9.0.8", + "better-sqlite3": "^12.11.1", "class-variance-authority": "^0.7.1", "clsx": "^1.2.1", + "docx-preview": "^0.3.7", + "fflate": "^0.8.3", "katex": "^0.16.47", "lucide-react": "^0.539.0", "mermaid": "^11.15.0", @@ -30,6 +33,7 @@ "react-markdown": "^9.0.1", "react-resizable-panels": "^3.0.6", "react-syntax-highlighter": "^15.6.1", + "read-excel-file": "^9.3.2", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", @@ -48,6 +52,7 @@ "@eslint/js": "^9", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.9", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -62,7 +67,8 @@ "tailwindcss": "^3.4.4", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.3", - "typescript-eslint": "^8.54.0" + "typescript-eslint": "^8.54.0", + "vitest": "^3.2.4" }, "engines": { "node": ">=20" @@ -407,6 +413,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2187,6 +2635,356 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2240,6 +3038,27 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -2502,6 +3321,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3254,6 +4080,147 @@ "d3-transition": "^3.0.1" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3527,6 +4494,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -3666,6 +4643,20 @@ "node": ">=6.0.0" } }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3679,6 +4670,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", @@ -3737,6 +4748,40 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3849,6 +4894,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3906,6 +4968,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3944,6 +5016,12 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -4034,6 +5112,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -4691,6 +5775,40 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4757,7 +5875,6 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -4808,10 +5925,19 @@ "node": ">=0.10.0" } }, + "node_modules/docx-preview": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.7.tgz", + "integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==", + "license": "Apache-2.0", + "dependencies": { + "jszip": ">=3.0.0" + } + }, "node_modules/dompurify": { - "version": "3.4.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", - "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -4846,6 +5972,15 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -4975,6 +6110,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -5045,6 +6187,48 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5497,6 +6681,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5513,6 +6707,25 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5593,6 +6806,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5606,6 +6825,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5695,6 +6920,12 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5850,6 +7081,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -5906,6 +7143,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/hachure-fill": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", @@ -6402,6 +7645,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6412,6 +7675,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6449,6 +7718,18 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -7097,6 +8378,54 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/katex": { "version": "0.16.47", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", @@ -7210,6 +8539,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -7282,6 +8620,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lowlight": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", @@ -7315,6 +8660,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -8296,6 +9651,18 @@ "node": ">=8.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mini-svg-data-uri": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", @@ -8323,12 +9690,17 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8374,6 +9746,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -8478,6 +9856,30 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -8497,6 +9899,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.46", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", @@ -8693,6 +10101,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8817,6 +10234,12 @@ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8900,6 +10323,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9164,6 +10604,33 @@ "dev": true, "license": "MIT" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9274,6 +10741,12 @@ "node": ">=6" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -9296,6 +10769,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9327,6 +10810,30 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -9488,6 +10995,34 @@ "pify": "^2.3.0" } }, + "node_modules/read-excel-file": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.3.2.tgz", + "integrity": "sha512-+zgqv/f6sll72omYA5kmlKf9W8ws7L+E/ZfhYpr8jJCFvOY5urGf4AnbwCIrtIUOFsT4LrpqYeMSleHQRJNnBg==", + "license": "MIT", + "dependencies": { + "fflate": "^0.8.3", + "saxen": "^11.0.2", + "unzipper-esm": "^0.13.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -9861,6 +11396,51 @@ "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", "license": "Unlicense" }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, "node_modules/roughjs": { "version": "4.6.6", "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", @@ -9923,6 +11503,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -9964,6 +11564,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxen": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/saxen/-/saxen-11.1.0.tgz", + "integrity": "sha512-GOxBOAmiWVAytOHBuMlgFMZ4MAk+2Ny5QJpzM9I4IozfPLXq3FsDdIHNviTQGZGIx7G2mBkA+uySiJlYmSU1Gg==", + "license": "MIT", + "engines": { + "node": ">= 20.12" + } + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -10029,6 +11638,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -10186,6 +11801,58 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -10222,6 +11889,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -10236,6 +11917,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -10386,6 +12076,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -10619,6 +12329,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -10642,6 +12380,13 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -10699,6 +12444,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10780,6 +12555,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11088,6 +12875,19 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, + "node_modules/unzipper-esm": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/unzipper-esm/-/unzipper-esm-0.13.2.tgz", + "integrity": "sha512-lt8GtgDYV8YcAFZNQuLyR2QvHI8C/TstpgsdjUn9ZxiWLJgn+e5uW6DsO3e/HUJVuWD57ZLLFMZ9xk26tePuHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -11194,7 +12994,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/uuid": { @@ -11253,6 +13052,228 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -11368,6 +13389,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -11378,6 +13416,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 1dec587..d28564f 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,11 @@ "scripts": { "dev": "next dev --turbopack --port 4716", "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:dist": "node dist/server.js", + "test": "vitest run", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", @@ -57,8 +60,11 @@ "@radix-ui/react-slot": "^1.2.4", "@types/react-syntax-highlighter": "^15.5.13", "@types/uuid": "^9.0.8", + "better-sqlite3": "^12.11.1", "class-variance-authority": "^0.7.1", "clsx": "^1.2.1", + "docx-preview": "^0.3.7", + "fflate": "^0.8.3", "katex": "^0.16.47", "lucide-react": "^0.539.0", "mermaid": "^11.15.0", @@ -69,6 +75,7 @@ "react-markdown": "^9.0.1", "react-resizable-panels": "^3.0.6", "react-syntax-highlighter": "^15.6.1", + "read-excel-file": "^9.3.2", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", @@ -84,6 +91,7 @@ "@eslint/js": "^9", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.9", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -98,6 +106,7 @@ "tailwindcss": "^3.4.4", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.3", - "typescript-eslint": "^8.54.0" + "typescript-eslint": "^8.54.0", + "vitest": "^3.2.4" } } diff --git a/scripts/assemble-standalone.mjs b/scripts/assemble-standalone.mjs index 2bed798..a9463fd 100644 --- a/scripts/assemble-standalone.mjs +++ b/scripts/assemble-standalone.mjs @@ -4,7 +4,7 @@ // .next/static/ (NOT inside standalone — must be copied) // We copy everything into dist/ so the published package ships one folder that // `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"; const STANDALONE = ".next/standalone"; @@ -26,6 +26,37 @@ if (existsSync(PUBLIC)) { 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 // to just the runtime essentials (drops src/, configs, and local notes so // they never get published). diff --git a/scripts/verify-standalone.mjs b/scripts/verify-standalone.mjs new file mode 100644 index 0000000..347ba9b --- /dev/null +++ b/scripts/verify-standalone.mjs @@ -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).`); diff --git a/scripts/verify-workspace-isolation.mjs b/scripts/verify-workspace-isolation.mjs new file mode 100644 index 0000000..9cc3df7 --- /dev/null +++ b/scripts/verify-workspace-isolation.mjs @@ -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."); diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts new file mode 100644 index 0000000..80e61b2 --- /dev/null +++ b/src/app/api/config/route.ts @@ -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); +} diff --git a/src/app/api/conversations/[threadId]/async-tasks/[taskId]/route.ts b/src/app/api/conversations/[threadId]/async-tasks/[taskId]/route.ts new file mode 100644 index 0000000..9b32c8b --- /dev/null +++ b/src/app/api/conversations/[threadId]/async-tasks/[taskId]/route.ts @@ -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 | null { + if (!value || typeof value !== "object") return null; + for (const candidate of Object.values(value as Record)) { + if (candidate && typeof candidate === "object" && (candidate as { task_id?: unknown }).task_id === taskId) return candidate as Record; + } + 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 | 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, + 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 }); + } +} diff --git a/src/app/api/conversations/[threadId]/async-tasks/route.ts b/src/app/api/conversations/[threadId]/async-tasks/route.ts new file mode 100644 index 0000000..71d200e --- /dev/null +++ b/src/app/api/conversations/[threadId]/async-tasks/route.ts @@ -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)) { + if (!item || typeof item !== "object") continue; + const task = item as Record; + 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>, + 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 } + ); + } +} diff --git a/src/app/api/conversations/[threadId]/file-state/route.ts b/src/app/api/conversations/[threadId]/file-state/route.ts new file mode 100644 index 0000000..11d1430 --- /dev/null +++ b/src/app/api/conversations/[threadId]/file-state/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/conversations/[threadId]/route.ts b/src/app/api/conversations/[threadId]/route.ts new file mode 100644 index 0000000..c5ed82b --- /dev/null +++ b/src/app/api/conversations/[threadId]/route.ts @@ -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 | 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); + 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 | 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 | 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); + } +} diff --git a/src/app/api/conversations/[threadId]/runs/[runId]/cancel/route.ts b/src/app/api/conversations/[threadId]/runs/[runId]/cancel/route.ts new file mode 100644 index 0000000..83a23cd --- /dev/null +++ b/src/app/api/conversations/[threadId]/runs/[runId]/cancel/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/conversations/[threadId]/runs/[runId]/route.ts b/src/app/api/conversations/[threadId]/runs/[runId]/route.ts new file mode 100644 index 0000000..2693f5b --- /dev/null +++ b/src/app/api/conversations/[threadId]/runs/[runId]/route.ts @@ -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 | 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 } + ); + } +} diff --git a/src/app/api/conversations/[threadId]/runs/[runId]/stream/route.ts b/src/app/api/conversations/[threadId]/runs/[runId]/stream/route.ts new file mode 100644 index 0000000..edb2321 --- /dev/null +++ b/src/app/api/conversations/[threadId]/runs/[runId]/stream/route.ts @@ -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 | 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({ + 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 } + ); + } +} diff --git a/src/app/api/conversations/[threadId]/runs/route.test.ts b/src/app/api/conversations/[threadId]/runs/route.test.ts new file mode 100644 index 0000000..476fb16 --- /dev/null +++ b/src/app/api/conversations/[threadId]/runs/route.test.ts @@ -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: (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" + ); + }); +}); diff --git a/src/app/api/conversations/[threadId]/runs/route.ts b/src/app/api/conversations/[threadId]/runs/route.ts new file mode 100644 index 0000000..e662926 --- /dev/null +++ b/src/app/api/conversations/[threadId]/runs/route.ts @@ -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; + 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 { + 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 | 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 | 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 } + ); + } +} diff --git a/src/app/api/conversations/route.ts b/src/app/api/conversations/route.ts new file mode 100644 index 0000000..d0ed98a --- /dev/null +++ b/src/app/api/conversations/route.ts @@ -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 | 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 | 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 | 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); + } +} diff --git a/src/app/api/default-model/route.ts b/src/app/api/default-model/route.ts new file mode 100644 index 0000000..e5b4356 --- /dev/null +++ b/src/app/api/default-model/route.ts @@ -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, + }); +} diff --git a/src/app/api/deployment/assistant/route.ts b/src/app/api/deployment/assistant/route.ts new file mode 100644 index 0000000..4f8b34c --- /dev/null +++ b/src/app/api/deployment/assistant/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/evosci-config/route.ts b/src/app/api/evosci-config/route.ts deleted file mode 100644 index ccf5abe..0000000 --- a/src/app/api/evosci-config/route.ts +++ /dev/null @@ -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 { - 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}`, - }); -} diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index 0822784..80d199f 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { isCrossOrigin } from "@/lib/server/workspace"; +import { getActiveDeployment } from "@/lib/server/activeDeployment"; 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) { try { if (isCrossOrigin(request)) { return fail("Cross-origin model registry access is not allowed.", 403); } - const deploymentUrl = resolveDeploymentUrl(request) - .toString() - .replace(/\/$/, ""); - const headers: Record = {}; - const apiKey = request.headers.get("x-api-key"); - if (apiKey) headers["X-Api-Key"] = apiKey; - - const upstream = await fetch(`${deploymentUrl}/api/models`, { - headers, + const deployment = await getActiveDeployment(); + const upstream = await fetch(new URL("/api/models", deployment.langgraphApiUrl), { cache: "no-store", }); const body = await upstream.text(); diff --git a/src/app/api/provider-actions/route.ts b/src/app/api/provider-actions/route.ts new file mode 100644 index 0000000..2ff5221 --- /dev/null +++ b/src/app/api/provider-actions/route.ts @@ -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, + }); +} diff --git a/src/app/api/provider-profiles/route.ts b/src/app/api/provider-profiles/route.ts new file mode 100644 index 0000000..87e1619 --- /dev/null +++ b/src/app/api/provider-profiles/route.ts @@ -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); +} diff --git a/src/app/api/scheduled-tasks/route.ts b/src/app/api/scheduled-tasks/route.ts new file mode 100644 index 0000000..149c21b --- /dev/null +++ b/src/app/api/scheduled-tasks/route.ts @@ -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; + 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 | 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 | 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); + } +} diff --git a/src/app/api/usage/calls/route.ts b/src/app/api/usage/calls/route.ts new file mode 100644 index 0000000..c31ec48 --- /dev/null +++ b/src/app/api/usage/calls/route.ts @@ -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" }, + }); +} diff --git a/src/app/api/usage/capabilities/route.ts b/src/app/api/usage/capabilities/route.ts new file mode 100644 index 0000000..889c6f7 --- /dev/null +++ b/src/app/api/usage/capabilities/route.ts @@ -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" } } + ); +} diff --git a/src/app/api/usage/events/route.ts b/src/app/api/usage/events/route.ts new file mode 100644 index 0000000..a41683f --- /dev/null +++ b/src/app/api/usage/events/route.ts @@ -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, + }); +} diff --git a/src/app/api/usage/routes.test.ts b/src/app/api/usage/routes.test.ts new file mode 100644 index 0000000..860fdfd --- /dev/null +++ b/src/app/api/usage/routes.test.ts @@ -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" }); + }); +}); diff --git a/src/app/api/usage/sources/heartbeat/route.ts b/src/app/api/usage/sources/heartbeat/route.ts new file mode 100644 index 0000000..02b5890 --- /dev/null +++ b/src/app/api/usage/sources/heartbeat/route.ts @@ -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; + 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", + }); +} diff --git a/src/app/api/usage/status/route.ts b/src/app/api/usage/status/route.ts new file mode 100644 index 0000000..edb7541 --- /dev/null +++ b/src/app/api/usage/status/route.ts @@ -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" }, + }); +} diff --git a/src/app/api/usage/summary/route.ts b/src/app/api/usage/summary/route.ts new file mode 100644 index 0000000..0b74375 --- /dev/null +++ b/src/app/api/usage/summary/route.ts @@ -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" }, + }); +} diff --git a/src/app/api/workspace/download/route.ts b/src/app/api/workspace/download/route.ts index 37d8409..17b104a 100644 --- a/src/app/api/workspace/download/route.ts +++ b/src/app/api/workspace/download/route.ts @@ -6,10 +6,13 @@ import { spawn } from "child_process"; import { Readable } from "stream"; import { NextRequest, NextResponse } from "next/server"; import { - getWorkspaceDir, zipExcludeArgs, isCrossOrigin, } from "@/lib/server/workspace"; +import { + ConversationWorkspaceError, + resolveConversationWorkspace, +} from "@/lib/server/conversationWorkspace"; 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`); await zipWorkspace(workspaceDir, tmpFile, request.signal); @@ -106,7 +109,7 @@ export async function GET(request: NextRequest) { ? error.message : "Failed to package the workspace.", }, - { status: 400 } + { status: error instanceof ConversationWorkspaceError ? error.status : 400 } ); } } diff --git a/src/app/api/workspace/file/route.ts b/src/app/api/workspace/file/route.ts index 36ca7e2..dbf1909 100644 --- a/src/app/api/workspace/file/route.ts +++ b/src/app/api/workspace/file/route.ts @@ -4,13 +4,16 @@ import { basename, extname } from "path"; import { Readable } from "stream"; import { NextRequest, NextResponse } from "next/server"; import { - getWorkspaceDir, safeResolve, isCrossOrigin, writeWorkspaceFile, deleteWorkspaceFile, MAX_WORKSPACE_WRITE_BYTES, } 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 * `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 workspaceDir = await getWorkspaceDir(); + const { filesDir: workspaceDir } = await resolveConversationWorkspace(request); // safeResolve canonicalizes + re-checks containment, so a symlink can't be // used to read a file outside the workspace (or a hidden/internal entry). 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.", }, - { status: 400 } + { status: error instanceof ConversationWorkspaceError ? error.status : 400 } ); } } @@ -224,7 +227,7 @@ export async function PUT(request: NextRequest) { { status: 400 } ); } - const workspaceDir = await getWorkspaceDir(); + const { filesDir: workspaceDir } = await resolveConversationWorkspace(request); const result = await writeWorkspaceFile( workspaceDir, relPath, @@ -236,7 +239,7 @@ export async function PUT(request: NextRequest) { { 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) { return NextResponse.json({ error: "Missing path." }, { status: 400 }); } - const workspaceDir = await getWorkspaceDir(); + const { filesDir: workspaceDir } = await resolveConversationWorkspace(request); await deleteWorkspaceFile(workspaceDir, relPath); return NextResponse.json({ ok: true }); } catch (error) { @@ -263,7 +266,7 @@ export async function DELETE(request: NextRequest) { error: error instanceof Error ? error.message : "Failed to delete file.", }, - { status: 400 } + { status: error instanceof ConversationWorkspaceError ? error.status : 400 } ); } } diff --git a/src/app/api/workspace/preview/file/route.ts b/src/app/api/workspace/preview/file/route.ts new file mode 100644 index 0000000..41a1f6e --- /dev/null +++ b/src/app/api/workspace/preview/file/route.ts @@ -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; + 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, + } + ); + } +} diff --git a/src/app/api/workspace/preview/route.ts b/src/app/api/workspace/preview/route.ts new file mode 100644 index 0000000..edf5bdb --- /dev/null +++ b/src/app/api/workspace/preview/route.ts @@ -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, + } + ); + } +} diff --git a/src/app/api/workspace/route.ts b/src/app/api/workspace/route.ts index 0238996..2011992 100644 --- a/src/app/api/workspace/route.ts +++ b/src/app/api/workspace/route.ts @@ -2,11 +2,14 @@ import { promises as fs } from "fs"; import { extname } from "path"; import { NextRequest, NextResponse } from "next/server"; import { - getWorkspaceDir, safeResolve, isHiddenEntry, isCrossOrigin, } from "@/lib/server/workspace"; +import { + ConversationWorkspaceError, + resolveConversationWorkspace, +} from "@/lib/server/conversationWorkspace"; export const runtime = "nodejs"; @@ -90,7 +93,8 @@ export async function GET(request: NextRequest) { const relPath = request.nextUrl.searchParams.get("path") ?? ""; 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 stat = await fs.stat(dir); @@ -165,7 +169,6 @@ export async function GET(request: NextRequest) { path: relPath, parent, entries, - dir: workspaceDir, }); } catch (error) { return NextResponse.json( @@ -173,7 +176,7 @@ export async function GET(request: NextRequest) { error: error instanceof Error ? error.message : "Failed to list workspace.", }, - { status: 400 } + { status: error instanceof ConversationWorkspaceError ? error.status : 400 } ); } } diff --git a/src/app/api/workspace/upload/route.ts b/src/app/api/workspace/upload/route.ts index c29ceb4..0da2819 100644 --- a/src/app/api/workspace/upload/route.ts +++ b/src/app/api/workspace/upload/route.ts @@ -1,7 +1,11 @@ import { promises as fs } from "fs"; import { basename, dirname, resolve } from "path"; 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"; @@ -104,9 +108,8 @@ export async function POST(request: NextRequest) { return { file, fileName: sanitizeFileName(file.name) }; }); - // Lands in the working directory of the currently running deployment, so the - // agent can read the files via its workspace file tools. - const workspaceDir = await getWorkspaceDir(); + // Files always land in the selected conversation's private files root. + const { filesDir: workspaceDir } = await resolveConversationWorkspace(request); const uploadedFiles: { name: string; path: string; size: number }[] = []; const writtenPaths: string[] = []; try { @@ -139,7 +142,7 @@ export async function POST(request: NextRequest) { error: error instanceof Error ? error.message : "Failed to upload files.", }, - { status: 400 } + { status: error instanceof ConversationWorkspaceError ? error.status : 400 } ); } } diff --git a/src/app/components/ActionGroup.tsx b/src/app/components/ActionGroup.tsx index 5890353..504b799 100644 --- a/src/app/components/ActionGroup.tsx +++ b/src/app/components/ActionGroup.tsx @@ -1,12 +1,45 @@ "use client"; -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { ChevronRight, ChevronUp, Loader2 } from "lucide-react"; +import 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 { ActionRequest, ReviewConfig, ToolCall } from "@/app/types/types"; +import type { + ActionRequest, + ReviewConfig, + SubAgent, + ToolCall, +} from "@/app/types/types"; 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 { MarkdownContent } from "./MarkdownContent"; +import { SubAgentIndicator } from "./SubAgentIndicator"; +import { SubAgentSteps } from "./SubAgentSteps"; +import { ToolCallBox } from "./ToolCallBox"; +import { StreamingMarkdown } from "./StreamingMarkdown"; import { cn } from "@/lib/utils"; export interface GroupedActionItem { @@ -17,42 +50,47 @@ export interface GroupedActionItem { interface ActionGroupProps { 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; - /** From `useCollapseAgentActions` — user preference. */ 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; - /** Id of the LAST message in the entire processedMessages list. Used to - * decide which ChatMessage should receive actionRequests / reviewConfigsMap. */ lastMessageId: string | undefined; - // Pass-through ChatMessage props isLoading: boolean; actionRequests: ActionRequest[]; submittedActionRequestKeys: Set; onActionRequestSubmitted: (key: string) => void; reviewConfigsMap: Map | null; stream?: unknown; - onResumeInterrupt: (value: unknown) => void; + onResumeInterrupt: (value: unknown, nextReviewMode?: ReviewMode) => void; graphId?: string; - onEditMessage: (content: string) => void; autoApprove: boolean; subAgentSteps: Record; ui: any[] | undefined; - // CompactionSummary anchoring: rendered before the matching item inside the group. compactionAnchorId: string | null; summarizationEvent: { content: string; cutoffIndex: number } | null; } -// Last tool call name — what we surface in the header summary line. -function lastToolName(items: GroupedActionItem[]): string { - for (let i = items.length - 1; i >= 0; i--) { - const tcs = items[i].toolCalls; - if (tcs.length > 0) return tcs[tcs.length - 1].name || "tool"; - } - return "action"; +const compactMarkdownClass = + "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"; + +function toSubAgent(toolCall: ToolCall): SubAgent | null { + if (toolCall.name !== "task") return null; + const subAgentName = toolCall.args.subagent_type; + 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(function ActionGroup({ @@ -69,35 +107,45 @@ export const ActionGroup = React.memo(function ActionGroup({ stream, onResumeInterrupt, graphId, - onEditMessage, autoApprove, subAgentSteps, ui, compactionAnchorId, summarizationEvent, }) { - // Whether this group contains the message that an interrupt is currently - // asking the user to approve. Tool-approval interrupts attach to the latest - // assistant message — so if `lastMessageId` belongs to this group AND there - // 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(() => !defaultCollapsed); + const [open, setOpen] = useState(() => !defaultCollapsed); + const [expandedSubAgents, setExpandedSubAgents] = useState< + Record + >({}); const wasStreamingRef = useRef(isStreaming); - // Auto-collapse when streaming ends, but only if the user is at the bottom. - // Approvals never force-open; their controls render in the preview below. + const summary = useMemo(() => summarizeActionGroup(items), [items]); + 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(() => { const wasStreaming = wasStreamingRef.current; wasStreamingRef.current = isStreaming; @@ -110,27 +158,201 @@ export const ActionGroup = React.memo(function ActionGroup({ ) { setOpen(false); } - }, [isStreaming, hasPendingApproval, defaultCollapsed, isAtBottom]); + }, [defaultCollapsed, hasPendingApproval, isAtBottom, isStreaming]); - // One AI message can carry several tool calls, so count the actual actions - // rather than the number of message containers in this group. - const count = items.reduce((total, item) => total + item.toolCalls.length, 0); - const toolName = lastToolName(items); - const headerText = isStreaming - ? `${count} action${count === 1 ? "" : "s"} running — ${toolName}` - : `${count} action${count === 1 ? "" : "s"} — last: ${toolName}`; + const actionRequestsKey = useMemo( + () => + JSON.stringify( + actionRequests.map((request) => ({ + name: request.name, + args: request.args, + })) + ), + [actionRequests] + ); + const pendingReviewDecisionsRef = useRef>({}); + 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 ( +
+ toggleSubAgent(subAgent.id)} + isExpanded={expanded} + /> + {expanded && ( +
+
+
+ Input +
+ +
+ {steps.length > 0 && ( +
+
+ Steps +
+ +
+ )} + {subAgent.output && ( +
+
+ Output +
+ +
+ )} +
+ )} +
+ ); + } + + const toolUiComponent = ui?.find( + (entry) => entry.metadata?.tool_call_id === toolCall.id + ); + return ( + + 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 ( -
+
- {/* Collapsed approval preview — renders the single approval-bearing - message so the user can act without expanding the full timeline. - When open, this is empty and the same item renders inside the body. */} - {(() => { - if (open || !hasPendingApproval || lastMessageId === undefined) - 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 ( -
- -
- ); - })()} + + {!open && hasPendingApproval && ( +
+ {pendingApprovalCalls.map(renderToolCall)} +
+ )} + {open && ( -
- {items.map((item) => { - const isLastOverall = item.message.id === lastMessageId; - const messageUi = ui?.filter( - (u) => u.metadata?.message_id === item.message.id - ); - const showCompactionBefore = compactionAnchorId === item.message.id; - return ( - - {showCompactionBefore && summarizationEvent && ( - - )} - - - ); - })} - {/* Bottom collapse button — easy reach after scrolling through a long group. */} +
+
+ {items.map((item, itemIndex) => { + const content = extractStringFromMessageContent(item.message); + const showCompactionBefore = + compactionAnchorId === item.message.id; + const itemIsStreaming = + isStreaming && item.message.id === lastMessageId; + + return ( + + {showCompactionBefore && summarizationEvent && ( + + )} + {content.trim() && ( +
+ +
+ )} + {item.toolCalls.map(renderToolCall)} +
+ ); + })} +
)} -
+
); }); + +ActionGroup.displayName = "ActionGroup"; diff --git a/src/app/components/AgentsPanel.tsx b/src/app/components/AgentsPanel.tsx index 8df7ccb..317f075 100644 --- a/src/app/components/AgentsPanel.tsx +++ b/src/app/components/AgentsPanel.tsx @@ -13,7 +13,6 @@ import { } from "lucide-react"; import { useQueryState } from "nuqs"; import { toast } from "sonner"; -import { useClient } from "@/providers/ClientProvider"; import { cn } from "@/lib/utils"; import { extractStringFromMessageContent } from "@/app/utils/utils"; import { @@ -35,6 +34,10 @@ import { type SubAgentStep, } from "@/lib/subAgentActivity"; import { SubAgentSteps } from "@/app/components/SubAgentSteps"; +import { + getConversationAsyncTaskState, + runConversationAsyncTask, +} from "@/lib/conversationApi"; interface TaskDetail { loading: boolean; @@ -105,7 +108,6 @@ interface AgentsPanelProps { } export function AgentsPanel({ onReportToMainChat }: AgentsPanelProps) { - const client = useClient(); const [threadId] = useQueryState("threadId"); const { tasks, loaded, error, refresh } = useAsyncAgents(threadId); const [now, setNow] = useState(() => Date.now()); @@ -149,9 +151,10 @@ export function AgentsPanel({ onReportToMainChat }: AgentsPanelProps) { setChatBusy((b) => ({ ...b, [task.task_id]: true })); setChatError((e) => ({ ...e, [task.task_id]: null })); try { - const values = (await client.runs.wait(task.thread_id, task.agent_name, { - input: { messages: [{ type: "human", content: text }] }, - })) as { messages?: unknown[] } | null; + if (!threadId) throw new Error("Open the conversation before contacting an agent."); + const values = await runConversationAsyncTask(threadId, task.task_id, { + messages: [{ type: "human", content: text }], + }); if (!mountedRef.current) return; const messages = Array.isArray(values?.messages) ? values.messages : []; if (messages.length === 0) { @@ -268,9 +271,8 @@ export function AgentsPanel({ onReportToMainChat }: AgentsPanelProps) { })); (async () => { try { - const state = (await client.threads.getState(task.thread_id)) as { - values?: { messages?: unknown[] }; - }; + if (!threadId) throw new Error("Conversation is not selected."); + const state = await getConversationAsyncTaskState(threadId, task.task_id); if (cancelled) return; const { prompt, steps } = buildDetail(state.values?.messages ?? []); if (!running) { @@ -300,7 +302,7 @@ export function AgentsPanel({ onReportToMainChat }: AgentsPanelProps) { return () => { cancelled = true; }; - }, [expandedId, tasks, client]); + }, [expandedId, tasks, threadId]); const runningCount = useMemo(() => countRunning(tasks), [tasks]); diff --git a/src/app/components/BuiltinProvidersEditor.tsx b/src/app/components/BuiltinProvidersEditor.tsx new file mode 100644 index 0000000..14ee6b4 --- /dev/null +++ b/src/app/components/BuiltinProvidersEditor.tsx @@ -0,0 +1,1263 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + Bot, + CheckCircle2, + CircleAlert, + Eye, + EyeOff, + FlaskConical, + Loader2, + Plus, + RefreshCw, + RotateCcw, + Save, + Server, + Trash2, +} from "lucide-react"; +import { toast } from "sonner"; +import { invalidateAvailableModels } from "@/app/hooks/useAvailableModels"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { + BuiltinModelConfig, + BuiltinProviderDraft, + discoverBuiltinProviderModels, + fetchLegacyLlmConfig, + LegacyLlmConfigResponse, + LegacyLlmSecretField, + LegacyLlmValueField, + LegacyLlmValues, + saveLegacyLlmConfig, + testBuiltinProviderModel, +} from "@/lib/legacyLlmConfig"; +import { cn } from "@/lib/utils"; + +interface BuiltinProviderDefinition { + id: string; + label: string; + apiKeyField?: LegacyLlmSecretField; + baseUrlField?: LegacyLlmValueField; + authModeField?: LegacyLlmValueField; + baseUrlPlaceholder?: string; +} + +interface ModelTestState { + status: "success" | "error"; + message: string; +} + +interface AddModelDraft { + providerId: string; + id: string; + name: string; + model_id: string; +} + +type ProviderConfigField = LegacyLlmValueField | LegacyLlmSecretField; + +const BUILTIN_PROVIDERS: BuiltinProviderDefinition[] = [ + { + id: "anthropic", + label: "Anthropic", + apiKeyField: "anthropic_api_key", + baseUrlField: "anthropic_base_url", + authModeField: "anthropic_auth_mode", + baseUrlPlaceholder: "https://api.anthropic.com", + }, + { + id: "openai", + label: "OpenAI", + apiKeyField: "openai_api_key", + authModeField: "openai_auth_mode", + }, + { + id: "google-genai", + label: "Google GenAI", + apiKeyField: "google_api_key", + }, + { id: "nvidia", label: "NVIDIA", apiKeyField: "nvidia_api_key" }, + { + id: "minimax", + label: "MiniMax", + apiKeyField: "minimax_api_key", + baseUrlField: "minimax_base_url", + baseUrlPlaceholder: "https://api.minimaxi.com/anthropic", + }, + { + id: "siliconflow", + label: "SiliconFlow", + apiKeyField: "siliconflow_api_key", + }, + { + id: "openrouter", + label: "OpenRouter", + apiKeyField: "openrouter_api_key", + }, + { + id: "deepseek", + label: "DeepSeek", + apiKeyField: "deepseek_api_key", + }, + { id: "zhipu", label: "Zhipu", apiKeyField: "zhipu_api_key" }, + { + id: "zhipu-code", + label: "Zhipu Code", + apiKeyField: "zhipu_api_key", + }, + { + id: "volcengine", + label: "Volcengine", + apiKeyField: "volcengine_api_key", + }, + { + id: "dashscope", + label: "DashScope", + apiKeyField: "dashscope_api_key", + }, + { + id: "dashscope-code", + label: "DashScope Code", + apiKeyField: "dashscope_api_key", + }, + { + id: "moonshot", + label: "Moonshot", + apiKeyField: "moonshot_api_key", + }, + { + id: "kimi-coding", + label: "Kimi Coding", + apiKeyField: "kimi_api_key", + }, + { + id: "custom-openai", + label: "Custom OpenAI", + apiKeyField: "custom_openai_api_key", + baseUrlField: "custom_openai_base_url", + baseUrlPlaceholder: "https://api.example.com/v1", + }, + { + id: "custom-anthropic", + label: "Custom Anthropic", + apiKeyField: "custom_anthropic_api_key", + baseUrlField: "custom_anthropic_base_url", + baseUrlPlaceholder: "https://api.example.com", + }, + { + id: "ollama", + label: "Ollama", + baseUrlField: "ollama_base_url", + baseUrlPlaceholder: "http://127.0.0.1:11434", + }, +]; + +function providerFields( + provider: BuiltinProviderDefinition +): ProviderConfigField[] { + const result: ProviderConfigField[] = []; + if (provider.apiKeyField) result.push(provider.apiKeyField); + if (provider.baseUrlField) result.push(provider.baseUrlField); + if (provider.authModeField) result.push(provider.authModeField); + return result; +} + +function uniqueProviderOptions(current: string): BuiltinProviderDefinition[] { + if ( + !current || + BUILTIN_PROVIDERS.some((provider) => provider.id === current) + ) { + return BUILTIN_PROVIDERS; + } + return [{ id: current, label: current }, ...BUILTIN_PROVIDERS]; +} + +function normalizeModelAlias(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[^a-z0-9]+/, "") + .replace(/-+$/g, "") + .slice(0, 64); +} + +function nextModelAlias(base: string, used: Iterable): string { + const existing = new Set(used); + if (!existing.has(base)) return base; + let suffix = 2; + while (existing.has(`${base}-${suffix}`)) suffix += 1; + return `${base}-${suffix}`; +} + +function discoveredModelAlias( + modelId: string, + usedAliases: Set +): string { + const segments = modelId.split("/").filter(Boolean); + const base = + normalizeModelAlias(segments.at(-1) || modelId) || "configured-model"; + const alias = nextModelAlias(base, usedAliases); + usedAliases.add(alias); + return alias; +} + +function modelTestKey(model: BuiltinModelConfig): string { + return `${model.provider}:${model.id}:${model.model_id}`; +} + +export function BuiltinProvidersEditor() { + const [config, setConfig] = useState(null); + const [values, setValues] = useState(null); + const [selectedProviderId, setSelectedProviderId] = useState(""); + const [modelCatalog, setModelCatalog] = useState( + null + ); + const [secretDrafts, setSecretDrafts] = useState< + Partial> + >({}); + const [clearSecrets, setClearSecrets] = useState>( + () => new Set() + ); + const [visibleSecret, setVisibleSecret] = + useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [discoveringProviderId, setDiscoveringProviderId] = useState< + string | null + >(null); + const [testingModelKey, setTestingModelKey] = useState(null); + const [modelTests, setModelTests] = useState>( + {} + ); + const [addModelDraft, setAddModelDraft] = useState( + null + ); + const [error, setError] = useState(null); + + const applyResponse = (response: LegacyLlmConfigResponse) => { + setConfig(response); + setValues(response.values); + setModelCatalog(response.model_catalog); + setSecretDrafts({}); + setClearSecrets(new Set()); + setVisibleSecret(null); + setError(response.model_catalog_error); + setSelectedProviderId((current) => + current && BUILTIN_PROVIDERS.some((provider) => provider.id === current) + ? current + : BUILTIN_PROVIDERS.some( + (provider) => provider.id === response.values.provider + ) + ? response.values.provider + : "anthropic" + ); + }; + + const loadConfig = async () => { + setLoading(true); + setError(null); + try { + applyResponse(await fetchLegacyLlmConfig()); + } catch (reason) { + setError( + reason instanceof Error + ? reason.message + : "Could not load built-in model configuration." + ); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadConfig(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const selectedProvider = + BUILTIN_PROVIDERS.find((provider) => provider.id === selectedProviderId) ?? + BUILTIN_PROVIDERS[0]; + const selectedModels = (modelCatalog ?? []).filter( + (model) => model.provider === selectedProvider.id + ); + + const hasChanges = useMemo(() => { + if (!config || !values) return false; + return ( + JSON.stringify(values) !== JSON.stringify(config.values) || + JSON.stringify(modelCatalog) !== JSON.stringify(config.model_catalog) || + Object.values(secretDrafts).some((value) => Boolean(value?.trim())) || + clearSecrets.size > 0 + ); + }, [clearSecrets, config, modelCatalog, secretDrafts, values]); + + const updateValue = (field: LegacyLlmValueField, value: string) => { + setValues((current) => + current ? { ...current, [field]: value } : current + ); + }; + + const updateSecret = (field: LegacyLlmSecretField, value: string) => { + setSecretDrafts((current) => ({ ...current, [field]: value })); + setClearSecrets((current) => { + if (!current.has(field)) return current; + const next = new Set(current); + next.delete(field); + return next; + }); + }; + + const toggleClearSecret = (field: LegacyLlmSecretField) => { + setSecretDrafts((current) => ({ ...current, [field]: "" })); + setVisibleSecret((current) => (current === field ? null : current)); + setClearSecrets((current) => { + const next = new Set(current); + if (next.has(field)) next.delete(field); + else next.add(field); + return next; + }); + }; + + const providerActionDraft = ( + provider: BuiltinProviderDefinition + ): BuiltinProviderDraft => { + const draft: BuiltinProviderDraft = { id: provider.id }; + if ( + provider.baseUrlField && + !config?.env_overrides[provider.baseUrlField] + ) { + draft.base_url = values?.[provider.baseUrlField] ?? ""; + } + if (provider.apiKeyField && !config?.env_overrides[provider.apiKeyField]) { + const secret = secretDrafts[provider.apiKeyField]; + if (secret?.trim()) draft.api_key = secret; + if (clearSecrets.has(provider.apiKeyField)) draft.clear_api_key = true; + } + return draft; + }; + + const addModels = ( + providerId: string, + discovered: Array<{ model_id: string; name: string }> + ) => { + const current = modelCatalog ?? []; + const providerModels = current.filter( + (model) => model.provider === providerId + ); + const existingModelIds = new Set( + providerModels.map((model) => model.model_id.trim()).filter(Boolean) + ); + const usedAliases = new Set(providerModels.map((model) => model.id)); + const additions: BuiltinModelConfig[] = discovered + .filter((model) => { + if (existingModelIds.has(model.model_id)) return false; + existingModelIds.add(model.model_id); + return true; + }) + .map((model) => ({ + provider: providerId, + id: discoveredModelAlias(model.model_id, usedAliases), + name: model.name || model.model_id, + model_id: model.model_id, + enabled: true, + })); + setModelCatalog([...current, ...additions]); + return additions.length; + }; + + const enableConfiguredOnlyMode = () => { + if (!values || !config) return; + const candidate = config.builtin_model_candidates.find( + (model) => + model.provider === values.provider && + (model.id === values.model || model.model_id === values.model) + ); + const defaultProviderIsBuiltIn = BUILTIN_PROVIDERS.some( + (provider) => provider.id === values.provider + ); + setModelCatalog( + candidate + ? [{ ...candidate, enabled: true }] + : defaultProviderIsBuiltIn + ? [ + { + provider: values.provider, + id: normalizeModelAlias(values.model) || "default-model", + name: values.model, + model_id: values.model, + enabled: true, + }, + ] + : [] + ); + }; + + const handleDiscoverModels = async () => { + setDiscoveringProviderId(selectedProvider.id); + setError(null); + try { + const discovered = await discoverBuiltinProviderModels( + providerActionDraft(selectedProvider) + ); + const additions = addModels(selectedProvider.id, discovered); + if (discovered.length === 0) { + toast.info("The provider returned no usable chat models."); + } else if (additions === 0) { + toast.info( + discovered.length + " models found; all are already configured." + ); + } else { + toast.success( + discovered.length + + " models found; " + + additions + + " added to the catalog." + ); + } + } catch (reason) { + const message = + reason instanceof Error + ? reason.message + : "Could not retrieve models from the provider."; + setError(message); + toast.error(message); + } finally { + setDiscoveringProviderId(null); + } + }; + + const addKnownModels = () => { + if (!config) return; + const candidates = config.builtin_model_candidates.filter( + (model) => model.provider === selectedProvider.id + ); + const additions = addModels(selectedProvider.id, candidates); + if (additions > 0) { + toast.success(additions + " known models added to the catalog."); + } else { + toast.info("All known models are already configured."); + } + }; + + const updateModel = ( + model: BuiltinModelConfig, + update: (model: BuiltinModelConfig) => BuiltinModelConfig + ) => { + setModelCatalog((current) => + (current ?? []).map((candidate) => + candidate.provider === model.provider && candidate.id === model.id + ? update(candidate) + : candidate + ) + ); + }; + + const removeModel = (model: BuiltinModelConfig) => { + setModelCatalog((current) => + (current ?? []).filter( + (candidate) => + candidate.provider !== model.provider || candidate.id !== model.id + ) + ); + }; + + const openAddModel = () => { + const id = nextModelAlias( + "model", + selectedModels.map((model) => model.id) + ); + setAddModelDraft({ + providerId: selectedProvider.id, + id, + name: "New model", + model_id: "", + }); + }; + + const confirmAddModel = () => { + if (!addModelDraft) return; + const id = normalizeModelAlias(addModelDraft.id); + const name = addModelDraft.name.trim(); + const modelId = addModelDraft.model_id.trim(); + if (!id || !name || !modelId) { + toast.error("Alias, display name, and upstream model ID are required."); + return; + } + if ( + (modelCatalog ?? []).some( + (model) => + model.provider === addModelDraft.providerId && model.id === id + ) + ) { + toast.error('Model alias "' + id + '" is already in use.'); + return; + } + setModelCatalog((current) => [ + ...(current ?? []), + { + provider: addModelDraft.providerId, + id, + name, + model_id: modelId, + enabled: true, + }, + ]); + setAddModelDraft(null); + }; + + const handleTestModel = async (model: BuiltinModelConfig) => { + const key = modelTestKey(model); + setTestingModelKey(key); + setError(null); + setModelTests((current) => { + const next = { ...current }; + delete next[key]; + return next; + }); + try { + const result = await testBuiltinProviderModel( + providerActionDraft(selectedProvider), + model + ); + const response = result.response.trim(); + const message = + "Connected in " + + result.latency_ms + + " ms" + + (response ? ": " + response : ""); + setModelTests((current) => ({ + ...current, + [key]: { status: "success", message }, + })); + toast.success(message); + } catch (reason) { + const message = + reason instanceof Error ? reason.message : "Model test failed."; + setModelTests((current) => ({ + ...current, + [key]: { status: "error", message }, + })); + toast.error(message); + } finally { + setTestingModelKey(null); + } + }; + + const handleSave = async () => { + if (!config || !values) return; + if (modelCatalog !== null) { + const defaultProviderIsBuiltIn = BUILTIN_PROVIDERS.some( + (provider) => provider.id === values.provider + ); + const defaultEnabled = modelCatalog.some( + (model) => + model.enabled && + model.provider === values.provider && + model.id === values.model + ); + if (defaultProviderIsBuiltIn && !defaultEnabled) { + const message = + 'Enable the default model "' + + values.model + + '" for provider "' + + values.provider + + '" before saving.'; + setError(message); + toast.error(message); + return; + } + } + setSaving(true); + setError(null); + try { + const secrets = Object.fromEntries( + Object.entries(secretDrafts).filter(([, value]) => value?.trim()) + ) as Partial>; + const response = await saveLegacyLlmConfig({ + revision: config.revision, + values, + secrets, + clear_secrets: [...clearSecrets], + model_catalog: modelCatalog, + }); + applyResponse(response); + invalidateAvailableModels(); + toast.success("Built-in model configuration saved."); + if (response.restart_required) { + toast.info("Restart EvoScientist to apply connection changes."); + } + } catch (reason) { + const message = + reason instanceof Error + ? reason.message + : "Could not save built-in model configuration."; + setError(message); + toast.error(message); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+ + Loading built-in providers... +
+ ); + } + + if (!config || !values) { + return ( +
+
+ {error || "Built-in model configuration is unavailable."} +
+ +
+ ); + } + + const apiKeyField = selectedProvider.apiKeyField; + const apiKeyStatus = apiKeyField ? config.secrets[apiKeyField] : null; + const selectedEnvOverrides = providerFields(selectedProvider) + .map( + (field) => + config.env_overrides[field as keyof typeof config.env_overrides] + ) + .filter((name): name is string => Boolean(name)); + const providerConfigured = Boolean( + (apiKeyField && apiKeyStatus?.configured) || + (selectedProvider.baseUrlField && + values[selectedProvider.baseUrlField]) || + (selectedProvider.authModeField && + values[selectedProvider.authModeField] === "oauth") + ); + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+

Default routing

+
+
+
+ + +
+
+ + + updateValue("model", event.target.value) + } + /> +
+
+ + + updateValue("auxiliary_provider", event.target.value) + } + /> +
+
+ + + updateValue("auxiliary_model", event.target.value) + } + /> +
+
+ +