diff --git a/.gitignore b/.gitignore index b7c78eab..e222517e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,36 @@ +# Build outputs and dependency caches. These are produced by `pnpm install` +# (`node_modules/`, `.pnpm-store/`), `pnpm build` (`dist/`, the Next export +# under `packages/webui/webapp/out`, Next's dev cache under +# `packages/webui/webapp/.next/`, and TypeScript's incremental build files), +# and various tool caches (`.cache/`, `.turbo/`). Keeping every one of them +# out of the working tree is what stops an un-ignored build artifact from +# leaking into the source inventory: `scripts/source-inventory.mjs` scans +# the working tree and records every path it does not skip, so a missed +# pattern here would silently publish the artifact as source. node_modules/ dist/ .turbo/ .cache/ .pnpm-store/ + +# TypeScript incremental build state. `tsc --incremental` (and Next's +# swc-loader) emit one next to every `tsconfig.json`; the root pattern +# catches every flavour (`*.tsbuildinfo` matches `tsconfig.tsbuildinfo`, +# `tsconfig.*.tsbuildinfo`, and the next-build-internal `.next/cache/.tsbuildinfo`). *.tsbuildinfo + +# Environment files. `.env.example` is the only one we keep: it documents +# the shape without holding values, and is the only file the standalone +# check expects to see. .env .env.* !.env.example + +# OS metadata — harmless but noisy; produced by Finder, Tarballs, etc. .DS_Store + +# Vendored research material kept inside the working tree for traceability. +# Desktop unpack (asar extract) — large, never published, regenerated by +# `packages/webui/desktop-unpacked/extract-asar.sh`. See SPEC.md inside that +# directory for the source revision that produced it. +packages/webui/desktop-unpacked/ diff --git a/docs/webui.md b/docs/webui.md index 74d422f8..6f4ee2e3 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -35,6 +35,39 @@ node dist/cli.js webui # dev Web UI on 127.0.0.1:18090 node dist/cli.js webui --port 8123 # keep the installed one free ``` +### One-shot dev launcher (frontend + backend, hot reload) + +When iterating on the Next.js frontend in `packages/webui/webapp/` you want both +the Node backend (port 18090, serves `/api/*`) and the Next dev server (port +18091, with HMR, proxies `/api/*` → 18090) running at once. `pnpm run webui:dev` +boots both in a single shell, prefixes their output so you can tell which side +is talking, and tears them down together on Ctrl+C: + +```bash +pnpm run webui:dev # http://127.0.0.1:18091/ ← open this in the browser +``` + +It is a thin wrapper over `node scripts/dev-webui.mjs` with no extra +dependencies. Stop the official `mcode` runtime first if port 18090 is busy +(`pkill -f "dist/cli.js webui"`), or pass `--port 28090` to `mcode webui` and +export `MCODE_WEBUI_ORIGIN=http://127.0.0.1:28090` so the dev proxy targets +the right backend. + +### Day-to-day webui commands (Next-aligned) + +| Command | What it does | +| ------------------------ | ---------------------------------------------------------------------------------------------------- | +| `pnpm run webui:dev` | Start both the backend (`:18090`) and Next dev (`:18091`, HMR) together; Ctrl+C cleans up both. | +| `pnpm run webui:build` | `next build` the webapp (`packages/webui/webapp/out/` is the static export). | +| `pnpm run webui:start` | Serve the already-built webui via `node packages/webui/server.js` on `:18090` (no HMR). | +| `pnpm run webui:typecheck`| `tsc --noEmit` over the webapp's TS sources. | +| `pnpm run webui:test` | Run all webui unit tests — backend (`test:webui`) + frontend (`test:webapp`). | + +`next start` is intentionally omitted: the webui ships as a `next export` static +build and the backend serves those files directly, so there is no Next server +runtime to start. ESLint is also not wired into the webapp yet — add it via +`npx next lint` once a `.eslintrc` is in place. + ### Docker The repository's Docker setup runs the branch in a **clean environment**: no @@ -78,7 +111,7 @@ The canonical disclosure is [`packages/webui/references/SECURITY-NOTES.md`](../p ## Architecture -See [`packages/webui/docs/ARCHITECTURE.md`](../packages/webui/docs/ARCHITECTURE.md) for the runtime topology, request lifecycle, and WebSocket event-stream contract. In short: `server.js` bootstraps an HTTP server; `server/router.js` applies the gate chain (CORS → origin/CSRF → LAN → token → rate limit → read-only) and dispatches to `server/routes/*`; `server/lib/*` holds one-concern modules; `acp.mjs` is the ACP client spawning the engine; `public/` is the SPA. +See [`packages/webui/docs/ARCHITECTURE.md`](../packages/webui/docs/ARCHITECTURE.md) for the runtime topology, request lifecycle, and SSE contract. In short: `packages/webui/server.js` registers the workspace import resolver and delegates to `server/bootstrap.js`; `server/router.js` applies the gate chain (CORS → origin/CSRF → LAN → token → rate limit → read-only) and dispatches to `server/routes/*`; `server/lib/*` holds one-concern modules; `acp.mjs` is the ACP client spawning the engine; `webapp/out/` (the Next static export) is the UI, with `public/trajectory/` and `public/auth-gate.html` (served from the export root) as the only remaining legacy assets. ## Trajectory studio @@ -98,7 +131,7 @@ pnpm test:webui # same, from the repository root (CI gate) node packages/webui/scripts/check-docs-alignment.mjs ``` -The package has zero npm runtime dependencies and requires Node 22.19+ (the trajectory studio additionally needs `node:sqlite`, floor 22.13). +The package has three runtime dependencies (`hono` + `@hono/node-server` for the HTTP layer, `@mavis/shared` for the workspace path contract) and requires Node 22.19+ (the trajectory studio additionally needs `node:sqlite`, floor 22.13). ## Origin diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index a1a73f33..55da184c 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -35,6 +35,28 @@ node dist/cli.js webui # dev Web UI on 127.0.0.1:18090 node dist/cli.js webui --port 8123 # keep the installed one free ``` +### 一键开发启动器(前后端 + 热重载) + +当你迭代 `packages/webui/webapp/` 里的 Next.js 前端时,需要 Node 后端(18090,提供 `/api/*`)与 Next 开发服务器(18091,带 HMR,会把 `/api/*` 代理到 18090)同时跑。`pnpm run webui:dev` 一个 shell 同时拉起两边,给它们的输出加前缀让你能分清谁在说话,并在 Ctrl+C 时一并清理: + +```bash +pnpm run webui:dev # http://127.0.0.1:18091/ ← 在浏览器中打开这个 +``` + +它只是 `node scripts/dev-webui.mjs` 的薄包装,不引入额外依赖。如果 18090 被占,先停掉官方 `mcode` 运行时(`pkill -f "dist/cli.js webui"`),或者给 `mcode webui` 传 `--port 28090` 并 `export MCODE_WEBUI_ORIGIN=http://127.0.0.1:28090`,让开发代理指向正确的后端。 + +### 日常 webui 命令(对齐 Next 原生能力) + +| 命令 | 作用 | +| ------------------------ | ------------------------------------------------------------------------------------------ | +| `pnpm run webui:dev` | 同时启动后端(`:18090`)与 Next dev(`:18091`,带 HMR);Ctrl+C 一起清理。 | +| `pnpm run webui:build` | `next build` 构建 webapp(产物在 `packages/webui/webapp/out/`,即静态导出目录)。 | +| `pnpm run webui:start` | 通过 `node packages/webui/server.js` 在 `:18090` 服务已构建好的 webui(不带 HMR)。 | +| `pnpm run webui:typecheck`| 对 webapp 的 TS 源码跑 `tsc --noEmit`。 | +| `pnpm run webui:test` | 跑全部 webui 单元测试——后端 `test:webui` + 前端 `test:webapp`。 | + +故意省略了 `next start`:webui 以 `next export` 静态构建并由后端直接服务这些文件,没有 Next server runtime 需要启动。ESLint 暂未集成进 webapp——待 `.eslintrc` 落位后再用 `npx next lint` 即可。 + ### Docker 仓库的 Docker 设置在**干净环境**中运行当前分支:不挂载任何 @@ -77,7 +99,7 @@ node dist/cli.js webui --host 0.0.0.0 --no-open # PORT defaults to 18080 ## 架构 -运行时拓扑、请求生命周期和 WebSocket 事件流契约见 [`packages/webui/docs/ARCHITECTURE.md`](../packages/webui/docs/ARCHITECTURE.md)。简言之:`server.js` 引导一个 HTTP 服务器;`server/router.js` 应用门禁链(CORS → origin/CSRF → LAN → token → rate limit → read-only)并分发到 `server/routes/*`;`server/lib/*` 存放单一职责模块;`acp.mjs` 是生成引擎的 ACP 客户端;`public/` 是 SPA。 +运行时拓扑、请求生命周期和 SSE 契约见 [`packages/webui/docs/ARCHITECTURE.md`](../packages/webui/docs/ARCHITECTURE.md)。简言之:`packages/webui/server.js` 注册 workspace 导入解析器,并委派给 `server/bootstrap.js`;`server/router.js` 应用门禁链(CORS → origin/CSRF → LAN → token → rate limit → read-only)并分发到 `server/routes/*`;`server/lib/*` 存放单一职责模块;`acp.mjs` 是生成引擎的 ACP 客户端;`webapp/out/`(Next 静态导出)是 UI,`public/trajectory/` 与 `public/auth-gate.html`(从导出根提供)是仅存的旧版资源。 ## 轨迹工作室 @@ -97,7 +119,7 @@ pnpm test:webui # same, from the repository root (CI gate) node packages/webui/scripts/check-docs-alignment.mjs ``` -该包没有任何 npm 运行时依赖,需要 Node 22.19+(轨迹工作室另外需要 `node:sqlite`,下限 22.13)。 +该包有三个运行时依赖(HTTP 层的 `hono` + `@hono/node-server`,以及工作区路径约定的 `@mavis/shared`),需要 Node 22.19+(轨迹工作室另外需要 `node:sqlite`,下限 22.13)。 ## 起源 diff --git a/package.json b/package.json index 082c68f1..4c213d93 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,17 @@ "start": "node dist/cli.js", "mcode": "node dist/cli.js", "mcode-web": "node dist/mcode-web.js", + "webui:dev": "node scripts/dev-webui.mjs", + "webui:build": "pnpm --filter @mavis/webui webapp:build", + "webui:start": "pnpm --filter @mavis/webui start", + "webui:typecheck": "pnpm --filter @mavis/webui webapp:typecheck", + "webui:test": "pnpm test:webui && pnpm test:webapp", "verify": "node scripts/verify.mjs", "test:smoke": "node --test test/smoke.test.mjs", "test:policy": "node scripts/run-vitest-suite.mjs policy", "test:byok": "node --test test/byok.test.mjs", "test:webui": "pnpm --filter @mavis/webui test", + "test:webapp": "pnpm --filter @mavis/webui test:webapp", "check:standalone": "node scripts/check-standalone-boundary.mjs", "check:tsconfig": "node scripts/gen-tsconfig-paths.mjs", "gen:tsconfig": "node scripts/gen-tsconfig-paths.mjs --write", diff --git a/packages/tui/src/acp/extensions.ts b/packages/tui/src/acp/extensions.ts index 9cbb7a80..4c76bc8d 100644 --- a/packages/tui/src/acp/extensions.ts +++ b/packages/tui/src/acp/extensions.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import * as acp from '@agentclientprotocol/sdk'; -import type { TuiSession } from '../runtime/port.js'; +import type { TuiAccountStatus, TuiSession } from '../runtime/port.js'; import type { TuiAcpRuntime } from './runtime.js'; export const TUI_ACP_EXTENSION_VERSION = 1; @@ -26,6 +26,7 @@ export const TUI_ACP_EXTENSION_METHODS = [ ...TUI_ACP_GOAL_METHODS, 'mcode/session/delegation/get', 'mcode/session/delegation/stop', + 'mcode/account/status', ] as const; export const TUI_ACP_EXTENSION_NOTIFICATIONS = [ @@ -54,6 +55,47 @@ export function tuiAcpExtensionCapabilities(runtime: Pick { + return { + status: account.status, + ...(account.authMode === undefined ? {} : { authMode: account.authMode }), + ...(account.modelSource === undefined ? {} : { modelSource: account.modelSource }), + ...(account.defaultModel === undefined ? {} : { defaultModel: account.defaultModel }), + ...(account.managedTokenPresent === undefined + ? {} + : { managedTokenPresent: account.managedTokenPresent }), + ...(account.identity?.name === undefined ? {} : { identity: { name: account.identity.name } }), + ...(account.tokenPlanQuotaState === undefined + ? {} + : { tokenPlanQuotaState: account.tokenPlanQuotaState }), + ...(account.tokenPlanSummary === undefined + ? {} + : { tokenPlan: { ...account.tokenPlanSummary } }), + ...(account.tokenPlanQuota === undefined + ? {} + : { + quota: { + fiveHour: account.tokenPlanQuota.fiveHour, + weekly: account.tokenPlanQuota.weekly, + }, + }), + warnings: [...account.warnings], + }; +} + export interface RegisterTuiAcpExtensionsOptions { readonly app: acp.AgentApp; readonly runtime: TuiAcpRuntime; @@ -93,6 +135,12 @@ export function registerTuiAcpExtensions(options: RegisterTuiAcpExtensionsOption activateSession(params.sessionId, client), ); + options.app.onRequest('mcode/account/status', parseOptionalSessionRequest, async ({ params }) => + projectAccountStatus( + await options.runtime.getAccountStatus(params.sessionId, { includeMembership: true }), + ), + ); + options.app.onRequest('mcode/session/steer', parseTextRequest, async ({ params }) => { resolve(params.sessionId); const expectedTurnId = options.activePromptTurnId(params.sessionId); @@ -263,6 +311,18 @@ function requireSession( return session; } +/** + * `mcode/account/status` answers before a session exists (the client's account + * card is visible then too), so its session id is optional. + */ +function parseOptionalSessionRequest(value: unknown): { sessionId?: string } { + if (value === undefined || value === null) return {}; + const record = requireRecord(value); + const sessionId = record.sessionId; + if (sessionId === undefined || sessionId === null || sessionId === '') return {}; + return { sessionId: requireText(sessionId, 'sessionId') }; +} + function parseSessionRequest(value: unknown): { sessionId: string } { const record = requireRecord(value); return { sessionId: requireText(record.sessionId, 'sessionId') }; diff --git a/packages/webui/README.md b/packages/webui/README.md index 9a84b2a5..36bfbad2 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -3,8 +3,9 @@ **English** | [简体中文](README.zh-CN.md) > **Browser frontend for the MiniMax Code agent runtime.** -> Streams `mcode acp` / `mcode exec` sessions over HTTP/WebSocket. Zero npm -> dependencies; runs on Node 22.19+. +> Streams `mcode acp` / `mcode exec` sessions over HTTP/SSE. Three +> runtime deps (`hono`, `@hono/node-server`, `@mavis/shared`); runs on +> Node 22.19+. The Web UI is a first-class part of this repository — the same engine that powers the TUI (`mcode acp`, Agent Client Protocol over stdio) drives the @@ -29,7 +30,7 @@ node packages/webui/server.js The server binds loopback by default. LAN exposure is explicit opt-in (`--host` / `HOST` env, or the persisted `lanBind` setting). On first start a -token is generated and delivered to the browser over the event stream; non-local requests +token is generated and delivered to the browser over SSE; non-local requests must carry `?token=` or `Authorization: Bearer `. Recommended on non-loopback networks: @@ -44,16 +45,16 @@ mcode webui --host 0.0.0.0 | File | What | |------|------| -| `server.js` | HTTP + WebSocket server bootstrap | -| `server/` | Router, route modules, and pure libs (`server/lib/`) | +| `server.js` | Source-mode entry: registers the workspace import resolver, then loads the server. The built runtime runs the bundle at `dist/webui/server.js` instead | +| `server/` | Router, route modules, and pure libs (`server/lib/`); startup lives in `server/bootstrap.js` | | `acp.mjs` | `mcode acp` JSON-RPC client (spawns the engine over stdio) | -| `public/` | Static frontend SPA | +| `webapp/` | The UI: a Next.js App Router app, static-exported to `webapp/out` and served as the single static root | +| `public/` | Remaining static assets only — the trajectory studio's UI under `public/trajectory/` | | `server/trajectory/` | Session trajectory studio (read-only SQLite inspection) | | `references/SECURITY-NOTES.md` | **Canonical security disclosure** (read before exposing beyond loopback) | | `docs/` | ARCHITECTURE, API, CAPABILITIES, DESIGN, DESKTOP-ARCHITECTURE, DEVELOPMENT, TROUBLESHOOTING, CHANGELOG | -| `test/` | `node:test` suites | -| `checks/` | mocked unit checks (`t.mock.module`; need the module-mocks flag) | -| `scripts/` | docs-alignment checker, SBOM generator, test-db fixture builder | +| `test/` | `node:test` suites. The directory mirrors each subject module's path under `server/`: `test/lib/.{test.js,check.mjs}` for `server/lib/.js`, `test/routes/.{test.js,check.mjs}` for `server/routes/.js`, `test/server/.{test.js,check.mjs}` for the top-level router / app / server bootstrap. `*.check.mjs` files need `--experimental-test-module-mocks` because they use `t.mock.module`; `*.test.js` files do not. `test/tooling/` covers scripts under `scripts/`. Helpers (`_setup.js`, `mavis-sources.mjs`) live in `test/helpers/`. `test/integration/`, `test/matrix/`, `test/trajectory/`, and `test/fixtures/` are unchanged. | +| `scripts/` | docs-alignment checker, SBOM generator, test-db fixture builder, desktop-reference extractor | | `package.json` | Package metadata + manifest (`mcodeWebui.capabilities`) | ## Screenshots @@ -64,7 +65,7 @@ Real captures taken against a running v2.0.0 server — see | # | What it shows | |---|---| | 1 | **Startup** — empty chat view on first launch | -| 2 | **Mid-stream chat** — history restored, stream deltas in flight, tok/s meter | +| 2 | **Mid-stream chat** — history restored, SSE deltas in flight, tok/s meter | | 3 | **Settings panel** — Appearance / Language / LAN Access toggles | | 4 | **Chat input** — prompt typed, send/stop affordances, `/` and `@file` hints | | 5 | **Post-send + tool call** — assistant streaming, tool-call block auto-collapse | @@ -79,7 +80,7 @@ IDE integrations match on these strings. | Capability | One-line | |---|---| -| `chat-streaming` | stream deltas from `mcode acp` rendered token-by-token | +| `chat-streaming` | SSE deltas from `mcode acp` rendered token-by-token | | `tool-execution` | Bash / Read / Write / Edit forwarded from acp `tool_call` events | | `plan-mode` | Plan review modal with `agree` / `skip` / `add context` options | | `ask-user-tool` | 2-4 option question modal with `Other` free-text fallback | @@ -88,7 +89,7 @@ IDE integrations match on these strings. | `session-management` | List / create / switch / delete webui sessions | | `file-attachments` | Drag-drop / click / paste upload + `@path` injection | | `quota-usage` | `mmx quota show` + per-turn context window display | -| `bilingual-ui` | zh-CN / en locale toggle via `t(key)` lookup tables | +| `bilingual-ui` | zh-CN / en locale toggle via typed `t(MessageKey)` lookup | | `lan-sharing` | Loopback default; LAN exposure via explicit opt-in (`HOST` env / `lanBind` setting) + runtime on/off toggle | | `token-auth` | `?token=` / `Authorization: Bearer` for non-local requests | | `mobile-responsive` | Drawer at <900px, single column at <600px | @@ -125,7 +126,7 @@ degrades to sequential scans. - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — runtime topology, request lifecycle, module contracts - [`docs/DESKTOP-ARCHITECTURE.md`](docs/DESKTOP-ARCHITECTURE.md) — measured desktop and TUI architecture reference - [`docs/DESIGN.md`](docs/DESIGN.md) — design system of record (tokens, theme protocol, layout) and the desktop alignment contract -- [`docs/API.md`](docs/API.md) — HTTP/WebSocket surface +- [`docs/API.md`](docs/API.md) — HTTP/SSE surface - [`docs/CAPABILITIES.md`](docs/CAPABILITIES.md) — capability deep-dive - [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) — dev workflow, tests - [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) — common failures @@ -137,7 +138,7 @@ degrades to sequential scans. Read [`references/SECURITY-NOTES.md`](references/SECURITY-NOTES.md) before binding to anything other than loopback. Highlights: loopback bind by default, trusted-origin CORS, per-request `authorize()` gate (fail-closed audit), -independent anomaly REST snapshot channel, workspace containment, bounded uploads, +independent anomaly SSE channel, workspace containment, bounded uploads, rate limiting, no telemetry. ## License diff --git a/packages/webui/README.zh-CN.md b/packages/webui/README.zh-CN.md index de491d5a..1ba3d73d 100644 --- a/packages/webui/README.zh-CN.md +++ b/packages/webui/README.zh-CN.md @@ -3,8 +3,9 @@ > 简体中文 | [English](README.md) > **MiniMax Code 智能体运行时的浏览器前端。** -> 通过 HTTP/WebSocket 流式传输 `mcode acp` / `mcode exec` 会话。零 npm -> 依赖;运行于 Node 22.19+。 +> 通过 HTTP/SSE 流式传输 `mcode acp` / `mcode exec` 会话。三个 +> 运行时依赖(`hono`、`@hono/node-server`、`@mavis/shared`); +> 运行于 Node 22.19+。 Web UI 是本仓库的一等组成部分 —— 驱动 TUI(`mcode acp`,基于 stdio 的 Agent Client Protocol)的同一个引擎同时驱动浏览器前端,与终端 UI @@ -29,7 +30,7 @@ node packages/webui/server.js 服务器默认绑定回环地址。局域网暴露需显式选择开启 (`--host` / `HOST` 环境变量,或持久化的 `lanBind` 设置)。首次 -启动时会生成一个令牌并通过事件流发送给浏览器;非本地请求必须携带 +启动时会生成一个令牌并通过 SSE 发送给浏览器;非本地请求必须携带 `?token=` 或 `Authorization: Bearer `。 在非回环网络上推荐的做法: @@ -44,15 +45,15 @@ mcode webui --host 0.0.0.0 | 文件 | 说明 | |------|------| -| `server.js` | HTTP + WebSocket 服务器引导 | -| `server/` | 路由器、路由模块与纯函数库(`server/lib/`) | +| `server.js` | 源码模式入口:注册 workspace 导入解析器后加载服务端。构建产物运行的是 `dist/webui/server.js` 这个 bundle | +| `server/` | 路由器、路由模块与纯函数库(`server/lib/`);启动逻辑位于 `server/bootstrap.js` | | `acp.mjs` | `mcode acp` JSON-RPC 客户端(通过 stdio 派生引擎) | -| `public/` | 静态前端 SPA | +| `webapp/` | UI:Next.js App Router 应用,静态导出到 `webapp/out`,作为唯一的静态根被服务 | +| `public/` | 仅剩静态资源——轨迹工作室的界面位于 `public/trajectory/` | | `server/trajectory/` | 会话轨迹工作室(只读 SQLite 检视) | | `references/SECURITY-NOTES.md` | **权威安全披露**(在回环之外暴露前必读) | | `docs/` | ARCHITECTURE、API、CAPABILITIES、DEVELOPMENT、TROUBLESHOOTING、CHANGELOG | -| `test/` | `node:test` 测试套件 | -| `checks/` | 打桩单元检查(`t.mock.module`;需要 module-mocks 标志) | +| `test/` | `node:test` 测试套件。目录结构与 `server/` 下的被测模块路径一一对应:`test/lib/.{test.js,check.mjs}` 对应 `server/lib/.js`,`test/routes/.{test.js,check.mjs}` 对应 `server/routes/.js`,`test/server/.{test.js,check.mjs}` 对应顶层 router / app / server bootstrap。`*.check.mjs` 文件因使用 `t.mock.module` 需要 `--experimental-test-module-mocks` 标志;`*.test.js` 不需要。`test/tooling/` 覆盖 `scripts/` 下的脚本。辅助模块(`_setup.js`、`mavis-sources.mjs`)位于 `test/helpers/`。`test/integration/`、`test/matrix/`、`test/trajectory/`、`test/fixtures/` 保持不变。 | | `scripts/` | 文档对齐检查器、SBOM 生成器、测试数据库夹具构建器 | | `package.json` | 包元数据 + 清单(`mcodeWebui.capabilities`) | @@ -64,7 +65,7 @@ mcode webui --host 0.0.0.0 | # | 展示内容 | |---|---| | 1 | **启动** —— 首次启动时的空聊天视图 | -| 2 | **流式聊天中** —— 历史已恢复,流式增量正在传输,tok/s 仪表 | +| 2 | **流式聊天中** —— 历史已恢复,SSE 增量正在传输,tok/s 仪表 | | 3 | **设置面板** —— 外观 / 语言 / 局域网访问开关 | | 4 | **聊天输入** —— 已输入提示词,发送/停止控件,`/` 与 `@file` 提示 | | 5 | **发送后 + 工具调用** —— 助手流式输出,工具调用块自动折叠 | @@ -78,7 +79,7 @@ mcode webui --host 0.0.0.0 | 能力 | 一句话说明 | |---|---| -| `chat-streaming` | 来自 `mcode acp` 的流式增量逐令牌渲染 | +| `chat-streaming` | 来自 `mcode acp` 的 SSE 增量逐令牌渲染 | | `tool-execution` | 从 acp `tool_call` 事件转发的 Bash / Read / Write / Edit | | `plan-mode` | 计划审阅模态框,含 `agree` / `skip` / `add context` 选项 | | `ask-user-tool` | 2–4 个选项的提问模态框,带 `Other` 自由文本回退 | @@ -87,7 +88,7 @@ mcode webui --host 0.0.0.0 | `session-management` | WebUI 会话的列出 / 创建 / 切换 / 删除 | | `file-attachments` | 拖拽 / 点击 / 粘贴上传 + `@path` 注入 | | `quota-usage` | `mmx quota show` + 每轮上下文窗口显示 | -| `bilingual-ui` | 通过 `t(key)` 查找表实现 zh-CN / en 语言切换 | +| `bilingual-ui` | 通过类型化的 `t(MessageKey)` 查找实现 zh-CN / en 语言切换 | | `lan-sharing` | 默认回环;局域网暴露需显式选择开启(`HOST` 环境变量 / `lanBind` 设置)+ 运行时开/关切换 | | `token-auth` | 非本地请求使用 `?token=` / `Authorization: Bearer` | | `mobile-responsive` | <900px 时为抽屉式,<600px 时为单列 | @@ -122,7 +123,7 @@ FTS5)为 **>=22.19 <23 || >=24 <27**,与运行时自身的引擎范围一致 ## 文档 - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) —— 运行时拓扑、请求生命周期、模块契约 -- [`docs/API.md`](docs/API.md) —— HTTP/WebSocket 接口面 +- [`docs/API.md`](docs/API.md) —— HTTP/SSE 接口面 - [`docs/CAPABILITIES.md`](docs/CAPABILITIES.md) —— 能力深入解析 - [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) —— 开发工作流、测试 - [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) —— 常见故障 @@ -134,7 +135,7 @@ FTS5)为 **>=22.19 <23 || >=24 <27**,与运行时自身的引擎范围一致 在绑定到回环之外的任何地址之前,请阅读 [`references/SECURITY-NOTES.md`](references/SECURITY-NOTES.md)。要点: 默认绑定回环、可信来源 CORS、逐请求 `authorize()` 门禁(失败关闭的 -审计)、独立的异常 REST 快照通道、工作区围栏、受限上传、速率限制、无遥测。 +审计)、独立的异常 SSE 通道、工作区围栏、受限上传、速率限制、无遥测。 ## 许可证 diff --git a/packages/webui/acp.mjs b/packages/webui/acp.mjs index db7ad0cc..ddee8f07 100644 --- a/packages/webui/acp.mjs +++ b/packages/webui/acp.mjs @@ -20,22 +20,23 @@ import { spawn } from 'node:child_process' import { EventEmitter } from 'node:events' import { homedir } from 'node:os' import { existsSync } from 'node:fs' -import { join, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' +import { join, resolve } from 'node:path' +import { WEBUI_ROOT } from './server/lib/layout.js' -const __dirname = dirname(fileURLToPath(import.meta.url)) const DEFAULT_CWD = process.cwd() -// v1.0: mcode 可执行文件动态解析 — 之前硬编码 C:\Users\\... 绝对路径, -// 插件分发到别人机器上必然失效。 -// v2.1 (in-product): 优先级 env MCODE_CMD > MCODE_WEBUI_SELF_ENTRY(启动器注入) -// > 本仓库构建产物 dist/cli.js > ~/.minimax-code/mcode.cmd > PATH 里的 mcode +// esbuild inlines every workspace module into the webui entry, so +// `import.meta.url` math is the entry's URL for every module here. +// WEBUI_ROOT is the single source of truth that pins the layout +// (packages/webui/ in source, dist/webui/ in the bundle), so the +// repo-cli path resolves to /dist/cli.js in either layout. function resolveMcodeCmd() { if (process.env.MCODE_CMD) return process.env.MCODE_CMD const self = process.env.MCODE_WEBUI_SELF_ENTRY if (self && existsSync(self)) return self - // packages/webui/acp.mjs → ../../dist/cli.js - const repoCli = join(__dirname, '..', '..', 'dist', 'cli.js') + // /../../dist/cli.js — webui lives at packages/webui/ (source) + // or dist/webui/ (bundled), so two levels up always lands on the repo root. + const repoCli = resolve(WEBUI_ROOT, '..', '..', 'dist', 'cli.js') if (existsSync(repoCli)) return repoCli if (process.platform === 'win32') { const p = join(homedir(), '.minimax-code', 'mcode.cmd') @@ -56,21 +57,26 @@ export class McodeAcpClient extends EventEmitter { this.pending = new Map() // id → {resolve, reject, method} this.capabilities = null this.started = false + // `_alive` (not `alive`) because every existing caller gates on it as a + // truth signal for "this client still owns a usable subprocess". The + // process-level exit handler below flips it back to false, so a stale + // singleton (the previous PR's bug: `_mcodeAcpSingleton.alive` always + // undefined) is now actually detected and replaced on the next call. + this._alive = false + } + + get alive() { + return this._alive && this.child !== null && this.started === true } - // 启动 subprocess + initialize + 解析 capabilities async start() { if (this.started) return this.capabilities - // Windows: 直接 spawn mcode.cmd(Node CreateProcess 知道 .cmd shim,不用 cmd.exe 套) - // - cmd.exe /c mcode 会输出 Windows 横幅污染 stdout JSON 解析 - // Linux/macOS: spawn 'mcode' 走 PATH - // Windows: spawn('cmd.exe', ['/c', 'mcode.cmd', 'acp']) 是 node probe 验证能 work 的姿势 - // - 直接 spawn mcode.cmd + shell:false → Node 22+ EINVAL(不让直接 CreateProcess .cmd) - // - shell:true → Node 内置 cmd.exe 解释,但会输出 Windows 横幅污染 JSON - // - cmd.exe /c <.cmd> → cmd.exe 作为父进程,不解释不打印横幅,只 exec mcode.cmd - // v2.1 (in-product): MCODE_CMD 可能是本仓库的 cli.js 入口(dist/cli.js 或启动器注入) - // —— .js/.mjs 入口在当前 Node 下运行;解析结果在所有平台生效(之前 POSIX - // 硬编码 'mcode',忽略了 resolveMcodeCmd 的返回值)。 + // Windows .cmd shim handling: Node 22+ rejects `spawn('mcode.cmd', { shell:false })` + // with EINVAL (no direct CreateProcess for .cmd). spawn(cmd.exe, ['/c', mcode.cmd]) + // works because cmd.exe execs the shim without printing the Windows banner that + // shell:true or '/c mcode' would emit into stdout and corrupt the JSON parse. + // On Linux/macOS, plain `spawn('mcode')` walks PATH. .js/.mjs entries run under + // process.execPath on every platform. const resolved = resolveMcodeCmd() let cmd, args if (/\.(js|mjs)$/i.test(resolved)) { @@ -86,28 +92,32 @@ export class McodeAcpClient extends EventEmitter { this.child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, - shell: false, // 关键:false 让 cmd.exe 不打印横幅 + shell: false, }) - // U4 (2026-09-20): 子进程死亡信号必须在到达时让所有 pending 落定。 - // Node 24 对 spawn 失败(mcode 未安装 → ENOENT)只发 error+close, - // 不发 exit —— 之前 pending 只在 exit 里 reject,request('initialize') - // 永不落定,上层 await 链整体悬空(无 mcode 的 Linux 上 /api/state - // 永久无响应;开发机有 mcode 时是环境噪声假绿)。exit/close 双挂 + - // error 兜底,排水幂等,先到者生效。 + // Node 24 does NOT emit 'exit' on spawn failure (ENOENT when mcode is + // not installed) — only 'error' + 'close'. If pending requests were + // rejected only in 'exit', request('initialize') would hang forever, + // taking the whole /api/state await chain with it. Hook all three + // signals; _rejectAllPending is idempotent (cleared map → no-op). this.child.on('error', (e) => { this._rejectAllPending(new Error(`mcode acp child error: ${e.message}`)) - // 重发射仅在有人监听时进行 —— 裸 emit('error') 无监听会抛 - // Unhandled 'error' event,无全局兜底的嵌入方会直接崩溃进程 + // Bare emit('error') with no listener throws Unhandled 'error' + // event in Node's EventEmitter — embedders without a global handler + // would crash the process. Only re-emit when someone is listening. if (this.listenerCount('error') > 0) this.emit('error', e) else console.error(`[acp] mcode acp child error: ${e.message}`) }) this.child.on('exit', (code, signal) => { + this._alive = false + this.started = false this.emit('exit', { code, signal }) - // 拒绝所有 pending this._rejectAllPending(new Error(`mcode acp exited (code=${code} signal=${signal})`)) }) this.child.on('close', (code, signal) => { - // close 在子进程死亡后必然触发(含 exit 不发的 spawn 失败场景) + // 'close' fires after every child termination, including the + // ENOENT spawn-failure path that skips 'exit'. + this._alive = false + this.started = false this._rejectAllPending(new Error(`mcode acp closed (code=${code} signal=${signal})`)) }) this.child.stdout.setEncoding('utf8') @@ -116,13 +126,13 @@ export class McodeAcpClient extends EventEmitter { this.child.stderr.on('data', (c) => { if (this.debug) process.stderr.write('[acp stderr] ' + c) }) - // initialize this.capabilities = await this.request('initialize', { protocolVersion: 1, clientInfo: { name: 'mcode-webui', version: '0.1.0' }, capabilities: { mcpCapabilities: { http: false, sse: false } }, }) this.started = true + this._alive = true return this.capabilities } @@ -130,9 +140,9 @@ export class McodeAcpClient extends EventEmitter { return process.platform === 'win32' ? resolveMcodeCmd() : 'mcode' } - // U4 (2026-09-20): 排水拒绝全部 pending 请求 — 幂等(pending 清空后再调为 - // no-op)。子进程死亡信号(error/exit/close)任何一个到达都必须让等待方 - // 落定,否则调用方的 await 永久悬空。 + // Reject every pending request. Idempotent (calling on an already- + // cleared map is a no-op). Called from every child-death signal so + // awaiting callers always settle, never hang. _rejectAllPending(err) { for (const [, p] of this.pending) { p.reject(err) @@ -140,7 +150,6 @@ export class McodeAcpClient extends EventEmitter { this.pending.clear() } - // 解析 stdout(每行一条 JSON) _onData(chunk) { this.buf += chunk let nl @@ -158,7 +167,6 @@ export class McodeAcpClient extends EventEmitter { if (this.debug) process.stderr.write('[acp] non-json line: ' + line + '\n') return } - // 响应(带 id) if (typeof msg.id !== 'undefined' && (msg.result !== undefined || msg.error !== undefined)) { const p = this.pending.get(msg.id) if (p) { @@ -168,11 +176,8 @@ export class McodeAcpClient extends EventEmitter { } return } - // notification(method 但无 id) if (msg.method) { - // 内部 raw 事件 this.emit('notification', msg) - // 细粒度事件 if (msg.method === 'session/update' && msg.params?.update) { const u = msg.params.update this.emit('sessionUpdate', u) @@ -183,7 +188,6 @@ export class McodeAcpClient extends EventEmitter { } } - // 通用 request request(method, params) { if (!this.child) return Promise.reject(new Error('acp not started')) const id = ++this.nextId @@ -205,8 +209,6 @@ export class McodeAcpClient extends EventEmitter { this.child.stdin.write(JSON.stringify(msg) + '\n') } - // --- 高级 API --- - async newSession(cwd = this.cwd) { return await this.request('session/new', { cwd, mcpServers: [] }) } @@ -219,10 +221,31 @@ export class McodeAcpClient extends EventEmitter { return await this.request('session/list', cursor ? { cursor } : {}) } - // 发 prompt + 等 stopReason + 收集 thinking/answer - // onChunk({kind: 'thought'|'message'|'other'|'done', text?, update?, stopReason?}) - async prompt(sessionId, text, onChunk) { - // 先清理之前 listener,避免多个 prompt 串 + // onChunk callback shape: + // { kind: 'thought' | 'message' | 'tool_call' | 'tool_update' | + // 'usage' | 'plan_update' | 'plan_removed' | 'mode_update' | + // 'goal_update' | 'config_option_update' | + // 'session_info_update' | 'other' | 'done', + // text?, update?, stopReason?, usage? } + // tool_call / tool_update / usage / plan_update / mode_update / + // goal_update / config_option_update / session_info_update pass + // the raw session/update payload as `update`. done carries + // stopReason + usage aggregated from the response. + async prompt(sessionId, promptOrBlocks, onChunk) { + // NOTE: listeners are added per-prompt and removed when the + // response settles. Concurrent prompts on the same client will + // cross-talk on sessionUpdate — callers must serialize. + // + // `promptOrBlocks` is either the plain text (the common case) or a + // pre-built ACP content-block array. Blocks exist for attachments: + // the engine's `promptToText` (packages/tui/src/acp/agent.ts) accepts + // exactly `text` and `resource_link` and rejects anything else with + // "not supported in ACP P0" — so an uploaded file has to arrive as a + // `resource_link`, not as extra prose bolted onto the text and not as a + // `resource` / `image` block the engine would reject. + const blocks = Array.isArray(promptOrBlocks) + ? promptOrBlocks + : [{ type: 'text', text: promptOrBlocks }] return await new Promise((resolve, reject) => { // qa (OOM hardening): 不再累积 result.events — 每个 session/update // (含截图工具的 base64 rawOutput)都被 push 进数组且无任何消费者, @@ -239,51 +262,45 @@ export class McodeAcpClient extends EventEmitter { if (u.messageId) result.messageIds.add(u.messageId) try { onChunk?.({ kind: 'message', text: u.content.text }) } catch {} } else if (u.sessionUpdate === 'tool_call') { - // v0.5.bs: mcode acp 工具调用开始 — 透传完整 update 给上层(字段:toolCallId/title/name/status/rawInput) + // payload: {toolCallId, title, name, status, rawInput, ...} try { onChunk?.({ kind: 'tool_call', update: u }) } catch {} } else if (u.sessionUpdate === 'tool_call_update') { - // v0.5.bs: 工具完成 — 透传 rawOutput 等给上层 try { onChunk?.({ kind: 'tool_update', update: u }) } catch {} } else if (u.sessionUpdate === 'usage_update') { - // v0.5.bx: mcode acp 上下文用量({used, size, cost} — 当前 session 已用 vs 上限) - // 字段是累计值(不是 incremental),直接覆盖 cs.context + // payload: {used, size, cost} — cumulative values for the + // current session. Overwrite cs.context with these (do not + // add to prior counters). try { onChunk?.({ kind: 'usage', update: u }) } catch {} } else if (u.sessionUpdate === 'plan_update') { - // v0.5.bx-9: mcode acp 0.1.5+ 可能发 plan_update 事件(plan 模式 LLM 出方案) - // 字段: {sessionId, planId, title, summary, options: [{label, description}]} - // 0.1.4 probe 没发过(available_commands 也没 /plan),但先透传以备未来 + // payload: {sessionId, planId, title, summary, options:[{label,description}]} try { onChunk?.({ kind: 'plan_update', update: u }) } catch {} } else if (u.sessionUpdate === 'plan_removed') { - // v0.5.bx-9: 取消 plan 模式 try { onChunk?.({ kind: 'plan_removed', update: u }) } catch {} } else if (u.sessionUpdate === 'current_mode_update') { - // v0.5.bx-9: mcode 切到 plan/ask 模式时发 — 透传给 webui 决定弹 PlanMode/Ask modal try { onChunk?.({ kind: 'mode_update', update: u }) } catch {} } else if (u.sessionUpdate === 'goal_update') { - // v0.5.bx-9: 目标追踪(mcode 0.1.4 acp 没见,但 0.1.5+ 可能加) try { onChunk?.({ kind: 'goal_update', update: u }) } catch {} } else if (u.sessionUpdate === 'config_option_update') { - // v0.5.by: mcode acp 0.1.5 推的 config 变化 (如 permissionMode 被改) - // payload: { sessionId, key, value, ... } — 透传给上层, 上层按 key 分发 + // payload: {sessionId, key, value, ...} — dispatcher in the + // upper layer picks the field by `key`. try { onChunk?.({ kind: 'config_option_update', update: u }) } catch {} } else if (u.sessionUpdate === 'session_info_update') { - // v0.5.by: mcode acp 0.1.5 推的 session info 变化 (mcode docs 没列具体字段, 透传) try { onChunk?.({ kind: 'session_info_update', update: u }) } catch {} } else { try { onChunk?.({ kind: 'other', update: u }) } catch {} } } this.on('sessionUpdate', onUpdate) - // 发 prompt this.request('session/prompt', { sessionId, - prompt: [{ type: 'text', text }], + prompt: blocks, }).then((r) => { result.stopReason = r?.stopReason || 'end_turn' - // v0.5.bx: 捕获 usage 字段(Gcm schema: totalTokens/inputTokens/outputTokens/thoughtTokens/cachedReadTokens/cachedWriteTokens) - // mcode acp 0.1.3 把 usage 放在 session/prompt response 里,不发独立 usage_update event + // mcode acp 0.1.3 returns usage on the session/prompt response, + // not as a separate usage_update event. Schema (Gcm convention): + // totalTokens, inputTokens, outputTokens, thoughtTokens, + // cachedReadTokens, cachedWriteTokens. if (r && r.usage) result.usage = r.usage - // v0.5.bx-7: debug — 看 mcode 0.1.4 实际 response 结构 if (process.env.MCODE_ACP_DEBUG) { console.log('[acp.prompt.response]', JSON.stringify({ stopReason: r?.stopReason, @@ -312,6 +329,6 @@ export class McodeAcpClient extends EventEmitter { this.started = false } - // 别名:跟 child_process 的 child.kill() 接口一致,/api/stop 能直接用 + // Alias for child_process.child.kill() so /api/stop can call either. kill() { this.stop() } } diff --git a/packages/webui/checks/alerts-theme.check.mjs b/packages/webui/checks/alerts-theme.check.mjs deleted file mode 100644 index 42b6c2fd..00000000 --- a/packages/webui/checks/alerts-theme.check.mjs +++ /dev/null @@ -1,632 +0,0 @@ -// webui/checks/alerts-theme.check.mjs -// Mocked checks for the 2026-09-20 webui-manual-audit wave-2 frontend -// fixes (subagent D): -// -// D1 — anomaly-channel (alerts) surface. server/lib/alerts.js pushes -// system signals (failed chat send / spawn ENOENT, sqlite -// failure, …) on the INDEPENDENT alerts channel: the -// GET /api/alerts REST ring snapshot plus alerts.append / -// alerts.update control frames on /api/stream. The frontend -// had ZERO surface, so errors were invisible (P1). -// Covers: the state.js alerts store driven by fake stream -// frames (snapshot / append / update / replay dedup / malformed), the -// badge count + 99+ cap, mark-read-on-open, clear semantics, -// and the render.js renderAlerts DOM construction (level class, -// msg via textContent, src/session/×count/time meta line). -// D2 — usage button local visibility (renderUsage usage-hidden -// gating now remote-only). -// D3 — appearance button theme cycling (applyTheme effect on the -// documentElement data-theme attribute + localStorage persist + -// card checkbox re-sync). -// D4 — i18n completeness for the newly referenced keys. -// -// Same loading strategy as checks/authorize-modal.check.mjs: the REAL -// frontend modules under node:test, with minimal DOM globals installed -// BEFORE the dynamic import. No t.mock.module. - -import { test, describe, before, beforeEach, after } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { dirname, resolve } from "node:path"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const PLUGIN_ROOT = resolve(__dirname, ".."); -const appPath = (rel) => pathToFileURL(resolve(PLUGIN_ROOT, "public", "app", rel)).href; - -// ---------- minimal DOM / global fakes ---------- -// -// Richer than the authorize-modal makeEl on exactly the axes this -// surface exercises: className is settable AND backed by the classList -// set (renderUsage toggles usage-hidden), and assigning textContent -// clears children like the real DOM (renderAlerts rebuilds the list -// with `body.textContent = ''` + appendChild). - -function makeEl(id) { - const classes = new Set(); - const el = { - id, - children: [], - handlers: {}, - style: {}, - dataset: {}, - attrs: {}, - _text: "", - hidden: false, - disabled: false, - checked: false, - appendChild(c) { el.children.push(c); return c }, - addEventListener(ev, fn) { - if (!el.handlers[ev]) el.handlers[ev] = []; - el.handlers[ev].push(fn); - }, - setAttribute(k, v) { el.attrs[k] = String(v) }, - getAttribute(k) { return el.attrs[k] ?? null }, - removeAttribute(k) { delete el.attrs[k] }, - contains() { return false }, - focus() {}, - querySelector(sel) { - el._q = el._q || {}; - if (!el._q[sel]) el._q[sel] = makeEl(sel); - return el._q[sel]; - }, - set className(v) { - classes.clear(); - String(v).split(/\s+/).filter(Boolean).forEach((c) => classes.add(c)); - }, - get className() { return [...classes].join(" ") }, - set textContent(v) { el._text = String(v); el.children.length = 0 }, - get textContent() { return el._text }, - classList: { - add: (...c) => c.forEach((x) => classes.add(x)), - remove: (...c) => c.forEach((x) => classes.delete(x)), - toggle: (c, f) => { - const on = f === undefined ? !classes.has(c) : !!f; - if (on) classes.add(c); else classes.delete(c); - return on; - }, - contains: (c) => classes.has(c), - }, - }; - return el; -} - -const _els = {}; - -// Elements the assertions inspect directly. Everything else -// attachEvents touches is auto-vivified as a plain stub (the wiring -// under test only needs the element to exist). -const RICH_IDS = [ - // alerts surface (D1) - "btn-alerts", "alerts-badge", "alerts-popover", "alerts-clear", "alerts-popover-body", - // usage button (D2) - "btn-usage", "usage-popover", "usage-value", "usage-popover-body", - // appearance / theme (D3) - "btn-appearance", "appearance-card", "appearance-card-theme", "appearance-value", - "appearance-icon", -]; - -// FakeWebSocket — state.js opens ONE /api/stream socket per connect(); -// every server→client frame is a JSON text frame, so tests inject them -// through sws.onmessage({data: JSON.stringify(frame)}) exactly as the -// browser would deliver them. close() is silent on purpose: a -// deliberate close must NOT schedule the 3s reconnect retry. -class FakeWebSocket { - constructor(url) { - this.url = String(url); - this.onopen = null; - this.onmessage = null; - this.onclose = null; - this.closed = false; - this.sent = []; - } - send(data) { this.sent.push(data) } - close() { this.closed = true } -} - -const _store = new Map(); -const _fetchCalls = []; - -function installGlobals() { - // connect() reads the bare `location` global (browser semantics): - // protocol/host pick the ws:// URL; search/pathname/hash back the - // module-load token handling. - const loc = { search: "", pathname: "/", hash: "", protocol: "http:", host: "localhost:3000" }; - globalThis.window = { - location: loc, - history: { replaceState() {} }, - matchMedia: () => ({ matches: false }), // → default theme "light" - addEventListener() {}, - }; - globalThis.location = loc; - globalThis.localStorage = { - getItem: (k) => (_store.has(k) ? _store.get(k) : null), - setItem: (k, v) => _store.set(k, String(v)), - removeItem: (k) => _store.delete(k), - }; - globalThis.WebSocket = FakeWebSocket; - globalThis.document = { - documentElement: makeEl("html"), - body: makeEl("body"), - // Auto-vivify: attachEvents binds ~40 ids; only the ones we assert - // on need the rich stubs pre-registered above. - getElementById: (id) => _els[id] || (_els[id] = makeEl(id)), - createElement: (tag) => makeEl(tag), - querySelectorAll: () => [], - addEventListener() {}, - }; - try { - Object.defineProperty(globalThis, "navigator", { - value: { onLine: true, clipboard: { writeText: async () => {} } }, - configurable: true, writable: true, - }); - } catch {} - globalThis.fetch = async (url) => { - const u = String(url); - _fetchCalls.push({ url: u }); - if (u.startsWith("/api/alerts")) { - // The hello handler's ring-snapshot fetch: fire() queues the frame - // just before it triggers hello; capture it at call time. - const snap = _pendingAlertsSnapshot; - _pendingAlertsSnapshot = null; - return { ok: true, status: 200, json: async () => snap || { kind: "snapshot", alerts: [] } }; - } - return { ok: true, status: 200, json: async () => ({ ok: true }) }; - }; -} - -// ---------- SUT handles ---------- - -let stateMod, renderMod, eventsMod, i18nMod, sws; - -let _seq = 0; // control-frame seq counter -let _pendingAlertsSnapshot = null; // consumed by the fake /api/alerts fetch - -function pushHello() { - // The first server→client frame on /api/stream; its handler fetches - // the /api/alerts ring snapshot (and, on a first connect, the - // /api/state REST baseline). - sws.onmessage({ data: JSON.stringify({ - v: 1, type: "hello", - payload: { resumeSupported: true, latestSeq: 0, heartbeatMs: 30000, ringCapacity: 100, cid: "check" }, - }) }); -} - -// Drain the hello→fetch('/api/alerts')→_handleAlertFrame microtask -// chain before asserting; callers that fire a snapshot must await it. -function flush() { return new Promise((r) => setImmediate(r)); } - -function fire(frame) { - // Live alert frames arrive as /api/stream control frames - // (alerts.append / alerts.update). Ring snapshots are the one - // exception — production fetches them from GET /api/alerts on every - // hello — so route those through the exact same path. - assert.ok(sws && typeof sws.onmessage === "function", "state stream WebSocket must be connected"); - if (frame && typeof frame === "object" && frame.kind === "snapshot") { - _pendingAlertsSnapshot = frame; - pushHello(); - return; - } - // Non-snapshot input degrades to an alerts.append control frame — - // malformed strings exercise the same JSON.parse failure path the - // production dispatch does. - const kind = frame && typeof frame === "object" && frame.kind ? frame.kind : "append"; - sws.onmessage({ data: JSON.stringify({ - v: 1, seq: ++_seq, ts: Date.now(), type: "control", - payload: { name: `alerts.${kind}`, data: typeof frame === "string" ? frame : JSON.stringify(frame) }, - }) }); -} - -function click(id, target) { - const el = _els[id]; - assert.ok(el && el.handlers.click && el.handlers.click.length > 0, `${id} must have a click handler bound`); - for (const fn of el.handlers.click) fn({ stopPropagation() {}, target: target || el }); -} - -before(async (t) => { - installGlobals(); - for (const id of RICH_IDS) _els[id] = makeEl(id); - // Match the static markup's initial states. - _els["alerts-popover"].hidden = true; - _els["alerts-badge"].hidden = true; - _els["appearance-card"].hidden = true; - _els["usage-popover"].hidden = true; - - stateMod = await import(appPath("state.js")); - renderMod = await import(appPath("render.js")); - eventsMod = await import(appPath("events.js")); - i18nMod = await import(appPath("i18n.js")); - eventsMod.attachEvents(); // binds the bell + appearance + usage buttons ONCE - - // connect() schedules a 2s /api/refresh setTimeout + a 60s interval - // with REAL timers — swap in mock timers just for the call (same - // dance as authorize-modal.check.mjs). - t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] }); - try { - stateMod.connect(); - } finally { - t.mock.timers.reset(); - } - sws = stateMod.es; - assert.ok(sws instanceof FakeWebSocket, "connect() must open our fake WebSocket on /api/stream"); - assert.ok(String(sws.url).includes("/api/stream"), `unexpected state-stream url ${sws.url}`); -}); - -beforeEach(() => { - stateMod.clearAlerts(); - _els["alerts-popover"].hidden = true; - _els["alerts-badge"].hidden = true; - _els["btn-alerts"].attrs["aria-expanded"] = "false"; - _els["alerts-popover-body"].children.length = 0; - _els["alerts-popover-body"]._text = ""; -}); - -after(() => { - // nothing scheduled survives (mock timers were reset); nothing else to clean -}); - -// ============================================================ -// D1 — state.js alerts store driven by stream frames -// ============================================================ - -describe("D1 alerts channel — connection + queue", () => { - test("connect() rides /api/stream (token+cid suffix) and fetches /api/alerts on hello", () => { - assert.ok(sws instanceof FakeWebSocket, "state stream must be our fake WebSocket"); - assert.ok(String(sws.url).includes("/api/stream"), `unexpected url ${sws.url}`); - assert.ok(/cid=/.test(sws.url), "cid query must be present (per-client routing)"); - const before = _fetchCalls.filter((c) => c.url.startsWith("/api/alerts")).length; - pushHello(); - const after = _fetchCalls.filter((c) => c.url.startsWith("/api/alerts")).length; - assert.equal(after, before + 1, "hello must fetch the /api/alerts ring snapshot (REST)"); - }); - - test("snapshot frame replaces the list (newest first) and counts unseen ids as unread", async () => { - fire({ kind: "snapshot", alerts: [ - { id: "a-old", ts: 1, level: "error", msg: "old failure", src: "chat:send", count: 1 }, - { id: "a-new", ts: 2, level: "warn", msg: "new warning", src: "db", count: 1 }, - ] }); - await flush(); - const list = stateMod.getAlerts(); - assert.equal(list.length, 2); - assert.equal(list[0].id, "a-new", "server sends oldest→newest; store must be newest-first"); - assert.equal(list[1].id, "a-old"); - assert.equal(stateMod.getAlertsUnread(), 2); - }); - - test("append frame unshifts, bumps unread, and a replayed append is deduped by id", () => { - fire({ kind: "append", alert: { id: "b1", ts: 3, level: "error", msg: "spawn mcode ENOENT", src: "chat:send", count: 1 } }); - assert.equal(stateMod.getAlertsUnread(), 1); - assert.equal(stateMod.getAlerts()[0].id, "b1"); - fire({ kind: "append", alert: { id: "b1", ts: 3, level: "error", msg: "spawn mcode ENOENT", src: "chat:send", count: 1 } }); - assert.equal(stateMod.getAlertsUnread(), 1, "stream reconnect replay must not double-count"); - assert.equal(stateMod.getAlerts().length, 1); - }); - - test("snapshot replay after reconnect does not re-inflate the unread count", async () => { - fire({ kind: "append", alert: { id: "c1", ts: 4, level: "info", msg: "x", src: "system", count: 1 } }); - fire({ kind: "snapshot", alerts: [ - { id: "c1", ts: 4, level: "info", msg: "x", src: "system", count: 1 }, - ] }); - await flush(); - assert.equal(stateMod.getAlertsUnread(), 1, "already-seen id → fresh=0"); - assert.equal(stateMod.getAlerts().length, 1, "but the list is still the server truth"); - }); - - test("update frame merges count/ts in place without touching unread", () => { - fire({ kind: "append", alert: { id: "d1", ts: 5, level: "warn", msg: "sqlite locked", src: "db", count: 1 } }); - fire({ kind: "update", alert: { id: "d1", ts: 6, level: "warn", msg: "sqlite locked", src: "db", count: 4 } }); - const a = stateMod.getAlerts().find((x) => x.id === "d1"); - assert.equal(a.count, 4, "dedup merge (server 60s window) must surface ×count"); - assert.equal(a.ts, 6); - assert.equal(stateMod.getAlertsUnread(), 1); - }); - - test("update for an unknown/cleared id is ignored; malformed frames never throw", () => { - fire({ kind: "append", alert: { id: "e1", ts: 7, level: "info", msg: "keep", src: "system", count: 1 } }); - fire({ kind: "update", alert: { id: "no-such", ts: 7, level: "info", msg: "x", count: 2 } }); - fire("not json at all"); - fire(""); - fire({ kind: "append", alert: { nope: 1 } }); // no id → skipped - fire({ kind: "unknown-kind", alert: { id: "z" } }); // unknown kind → skipped - fire({ kind: "append" }); // missing alert → skipped - assert.equal(stateMod.getAlerts().length, 1); - assert.equal(stateMod.getAlertsUnread(), 1); - }); - - test("local ring cap mirrors the server (100) — badge caps at 99+", () => { - for (let i = 0; i < 101; i++) { - fire({ kind: "append", alert: { id: `cap-${i}`, ts: 1000 + i, level: "info", msg: `m${i}`, src: "system", count: 1 } }); - } - assert.equal(stateMod.getAlerts().length, 100, "ALERTS_MAX trim"); - assert.equal(stateMod.getAlerts()[0].id, "cap-100", "newest kept"); - assert.equal(stateMod.getAlertsUnread(), 101); - assert.equal(_els["alerts-badge"].textContent, "99+", "badge saturates instead of overflowing"); - }); - - test("malformed fields are coerced, never thrown: level whitelist + msg/src strings", () => { - fire({ kind: "append", alert: { id: "f1", ts: "junk", level: "catastrophic", msg: 42, src: "", sessionId: 12345, count: "3" } }); - const a = stateMod.getAlerts()[0]; - assert.equal(a.level, "info", "unknown level → info"); - assert.equal(a.msg, "42"); - assert.equal(a.src, "system"); - assert.equal(a.sessionId, "12345"); - assert.equal(a.count, 3); - assert.equal(a.ts, 0); - }); -}); - -// ============================================================ -// D1 — badge + popover render + interactions -// ============================================================ - -describe("D1 alerts surface — badge, popover, mark-read, clear", () => { - test("badge tracks unread (hidden at zero, count while > 0)", () => { - assert.equal(_els["alerts-badge"].hidden, true); - fire({ kind: "append", alert: { id: "g1", ts: 8, level: "error", msg: "boom", src: "chat:send", count: 1 } }); - assert.equal(_els["alerts-badge"].hidden, false); - assert.equal(_els["alerts-badge"].textContent, "1"); - }); - - test("bell click opens the popover, renders items, and marks everything read", () => { - fire({ kind: "append", alert: { id: "h1", ts: 9, level: "error", msg: "first", src: "chat:send", count: 1 } }); - fire({ kind: "append", alert: { id: "h2", ts: 10, level: "warn", msg: "second", src: "db", sessionId: "mvs_abcdefgh1234567890", count: 2 } }); - click("btn-alerts"); - assert.equal(_els["alerts-popover"].hidden, false, "popover opens"); - assert.equal(_els["btn-alerts"].attrs["aria-expanded"], "true"); - assert.equal(stateMod.getAlertsUnread(), 0, "marking-read on open"); - assert.equal(_els["alerts-badge"].hidden, true, "badge drops"); - // list still shows everything, newest first, via DOM construction - const body = _els["alerts-popover-body"]; - assert.equal(body.children.length, 2); - const [first, second] = body.children; - assert.equal(first.className, "alerts-item level-warn"); - assert.equal(first.children[1].children[0].textContent, "second"); - const meta = first.children[1].children[1].textContent; - assert.match(meta, /^db · session mvs_abcdefgh… · ×2 · \d{2}:\d{2}$/); - assert.equal(second.className, "alerts-item level-error"); - assert.match(second.children[1].children[1].textContent, /^chat:send · \d{2}:\d{2}$/, - "second item meta: src + time (no session hint, no ×count)"); - }); - - test("untrusted msg lands in textContent verbatim — no markup interpretation", () => { - const evil = ` `; - fire({ kind: "append", alert: { id: "x1", ts: 11, level: "error", msg: evil, src: "chat:send", count: 1 } }); - click("btn-alerts"); - const item = _els["alerts-popover-body"].children[0]; - assert.equal(item.children[1].children[0].textContent, evil); - assert.equal(item.children[1].children[0].children.length, 0, "no child nodes — text only"); - }); - - test("empty store renders the localized empty state", () => { - click("btn-alerts"); - const body = _els["alerts-popover-body"]; - assert.equal(body.children.length, 1); - assert.equal(body.children[0].className, "alerts-empty"); - assert.equal(body.children[0].textContent, "No alerts"); // en default dictionary - }); - - test("clear empties the list; the badge only returns for genuinely new alerts", async () => { - fire({ kind: "append", alert: { id: "i1", ts: 12, level: "info", msg: "x", src: "system", count: 1 } }); - click("btn-alerts"); // open + read - click("alerts-clear"); - assert.equal(stateMod.getAlerts().length, 0); - assert.equal(_els["alerts-popover-body"].children[0].className, "alerts-empty"); - assert.equal(stateMod.getAlertsUnread(), 0); - // reconnect replay of the SAME alert: history may come back, but the - // badge (the attention surface) must not re-inflate. - fire({ kind: "snapshot", alerts: [ - { id: "i1", ts: 12, level: "info", msg: "x", src: "system", count: 1 }, - ] }); - await flush(); - assert.equal(stateMod.getAlertsUnread(), 0, "cleared id is still 'seen'"); - fire({ kind: "append", alert: { id: "i2", ts: 13, level: "error", msg: "y", src: "chat:send", count: 1 } }); - assert.equal(stateMod.getAlertsUnread(), 1, "a genuinely new alert re-arms the badge"); - }); - - test("list is NOT rebuilt while the popover is closed (render storms are cheap)", () => { - fire({ kind: "append", alert: { id: "j1", ts: 14, level: "info", msg: "while closed", src: "system", count: 1 } }); - assert.equal(_els["alerts-popover-body"].children.length, 0, "closed popover renders nothing"); - assert.equal(_els["alerts-badge"].textContent, "1", "but the badge still updates"); - }); - - test("bell click toggles: a second click closes without clearing the list", () => { - fire({ kind: "append", alert: { id: "k1", ts: 15, level: "info", msg: "x", src: "system", count: 1 } }); - click("btn-alerts"); - click("btn-alerts"); - assert.equal(_els["alerts-popover"].hidden, true); - assert.equal(_els["btn-alerts"].attrs["aria-expanded"], "false"); - assert.equal(stateMod.getAlerts().length, 1, "closing is visual only"); - }); -}); - -// ============================================================ -// D3 — theme cycling through the appearance button + helpers -// ============================================================ - -describe("D3 appearance button — theme cycle + persistence", () => { - test("applyTheme() writes the current theme onto document.documentElement", () => { - i18nMod.applyTheme(); - assert.equal(globalThis.document.documentElement.attrs["data-theme"], "light", "no stored theme + matchMedia(false) → light"); - assert.equal(_els["appearance-value"].textContent, "Light"); - assert.equal(_els["appearance-card-theme"].checked, false, "card checkbox re-syncs"); - }); - - test("toggleTheme() cycles light↔dark, persists to localStorage, re-syncs the checkbox", () => { - i18nMod.toggleTheme(); - assert.equal(globalThis.document.documentElement.attrs["data-theme"], "dark"); - assert.equal(globalThis.localStorage.getItem("webui-theme"), "dark", "must persist across reloads"); - assert.equal(_els["appearance-value"].textContent, "Dark"); - assert.equal(_els["appearance-card-theme"].checked, true); - i18nMod.toggleTheme(); - assert.equal(globalThis.document.documentElement.attrs["data-theme"], "light"); - assert.equal(globalThis.localStorage.getItem("webui-theme"), "light"); - }); - - test("btn-appearance click cycles the theme AND opens the appearance card", () => { - // fresh localStorage state → light - globalThis.localStorage.removeItem("webui-theme"); - click("btn-appearance"); - assert.equal(globalThis.document.documentElement.attrs["data-theme"], "dark", "click must have a directly observable effect (the audit's probe)"); - assert.equal(globalThis.localStorage.getItem("webui-theme"), "dark"); - assert.equal(_els["appearance-card"].hidden, false, "card (quota master switch) stays reachable"); - assert.equal(_els["btn-appearance"].attrs["aria-expanded"], "true"); - click("btn-appearance"); - assert.equal(globalThis.document.documentElement.attrs["data-theme"], "light"); - assert.equal(_els["appearance-card"].hidden, true); - }); - - test("the card's own checkbox still toggles the theme (pre-existing path preserved)", () => { - const cb = _els["appearance-card-theme"]; - assert.ok(cb.handlers.change && cb.handlers.change.length > 0, "checkbox change handler must stay bound"); - for (const fn of cb.handlers.change) fn({ target: { checked: true } }); - assert.equal(globalThis.document.documentElement.attrs["data-theme"], "dark"); - assert.equal(globalThis.localStorage.getItem("webui-theme"), "dark"); - }); -}); - -// ============================================================ -// D2 — usage button visibility gating (local vs remote) -// ============================================================ - -describe("D2 usage button — visible in local mode", () => { - test("quotaEnabled=false + LOCAL body → no usage-hidden (button visible + clickable)", () => { - globalThis.document.body.classList.remove("is-remote"); - stateMod.setState({ quotaEnabled: false }); - stateMod.renderUsage(); - assert.equal(_els["btn-usage"].classList.contains("usage-hidden"), false, - "fresh local install must be able to reach the key configuration entry"); - }); - - test("quotaEnabled=false + REMOTE body → usage-hidden kept (original design)", () => { - globalThis.document.body.classList.add("is-remote"); - stateMod.setState({ quotaEnabled: false }); - stateMod.renderUsage(); - assert.equal(_els["btn-usage"].classList.contains("usage-hidden"), true); - globalThis.document.body.classList.remove("is-remote"); - }); - - test("quotaEnabled=true is visible in both modes", () => { - stateMod.setState({ quotaEnabled: true }); - stateMod.renderUsage(); - assert.equal(_els["btn-usage"].classList.contains("usage-hidden"), false); - globalThis.document.body.classList.add("is-remote"); - stateMod.renderUsage(); - assert.equal(_els["btn-usage"].classList.contains("usage-hidden"), false); - globalThis.document.body.classList.remove("is-remote"); - }); -}); - -// ============================================================ -// render.js pure helpers -// ============================================================ - -describe("alerts render helpers (pure)", () => { - test("formatAlertTime — HH:MM, zero-padded, junk → ''", () => { - // Local-time rendering (matches the rest of the UI's time display), - // so compute the expectation through Date the same way. - const d = new Date(12345); - const want = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; - assert.equal(renderMod.formatAlertTime(12345), want); - assert.match(renderMod.formatAlertTime(12345), /^\d{2}:\d{2}$/); - assert.equal(renderMod.formatAlertTime("junk"), ""); - assert.equal(renderMod.formatAlertTime(undefined), ""); - }); - - test("shortSessionHint truncates at 12 chars with an ellipsis", () => { - assert.equal(renderMod.shortSessionHint("mvs_abcdefgh1234567890"), "mvs_abcdefgh…"); - assert.equal(renderMod.shortSessionHint("short-id"), "short-id"); - assert.equal(renderMod.shortSessionHint(""), ""); - }); - - test("alertLevelLabel maps the three levels through i18n", () => { - assert.equal(renderMod.alertLevelLabel("error"), "Error"); - assert.equal(renderMod.alertLevelLabel("warn"), "Warning"); - assert.equal(renderMod.alertLevelLabel("info"), "Info"); - assert.equal(renderMod.alertLevelLabel("nonsense"), "nonsense"); - }); -}); - -// ============================================================ -// Static source guards (CodeQL js/xss-through-dom + markup + i18n) -// ============================================================ - -describe("static source guards", () => { - // v2 (2026-09-20 webui-manual-audit D1): normalize CRLF at read time — - // windows-latest checks out with git autocrlf, so every source file - // arrives with \r\n line endings and the slice() markers below (which - // embed a literal \n) never match, failing the guard on the ONLY real - // Windows CI we have. Normalizing to LF makes marker slicing checkout- - // agnostic: the sliced block is byte-identical to the LF-checkout run, - // so the innerHTML/regex assertions keep their exact semantics. - const readSrc = (p) => readFileSync(p, "utf8").replace(/\r\n/g, "\n"); - const RENDER_SRC = readSrc(resolve(PLUGIN_ROOT, "public", "app", "render.js")); - const STATE_SRC = readSrc(resolve(PLUGIN_ROOT, "public", "app", "state.js")); - const INDEX_SRC = readSrc(resolve(PLUGIN_ROOT, "public", "index.html")); - const I18N_SRC = readSrc(resolve(PLUGIN_ROOT, "public", "app", "i18n.js")); - - function slice(src, startMarker, endMarker) { - const s = src.indexOf(startMarker); - assert.ok(s !== -1, `start marker not found: ${startMarker}`); - const e = src.indexOf(endMarker, s); - assert.ok(e !== -1, `end marker not found after start: ${endMarker}`); - return src.slice(s, e + endMarker.length); - } - - test("alerts surface in render.js is DOM-only — zero innerHTML (CodeQL)", () => { - const block = slice( - RENDER_SRC, - "// v2 (2026-09-20 webui-manual-audit D1): anomaly-channel (alerts)\n// surface — begin alerts-surface", - "// v2 (2026-09-20 webui-manual-audit D1): alerts surface — end alerts-surface", - ); - assert.equal(/\.innerHTML\s*=/.test(block), false, - "alert msg/src/sessionId are untrusted stream wire data (server relays raw subprocess stderr)"); - assert.match(block, /createElement\(\s*['"]div['"]\s*\)/); - assert.match(block, /createElement\(\s*['"]span['"]\s*\)/); - assert.match(block, /\.textContent\s*=/); - }); - - test("alerts store in state.js never touches innerHTML either", () => { - const block = slice( - STATE_SRC, - "// v2 (2026-09-20 webui-manual-audit D1): anomaly-channel (alerts) store", - "// v2 (2026-09-20 webui-manual-audit D1): the alerts SSE connection.", - ); - assert.equal(/\.innerHTML\s*=/.test(block), false); - }); - - test("index.html carries the bell + popover mount (btn-alerts / badge / clear)", () => { - assert.match(INDEX_SRC, /id="btn-alerts"/); - assert.match(INDEX_SRC, /id="alerts-badge"/); - assert.match(INDEX_SRC, /id="alerts-popover"/); - assert.match(INDEX_SRC, /id="alerts-clear"/); - assert.match(INDEX_SRC, /id="alerts-popover-body"/); - }); - - test("the frontend subscribes to BOTH the /api/alerts snapshot and the /api/stream state endpoint", () => { - const block = slice(STATE_SRC, "fetch('/api/alerts'", "// v0.5.ak: user footer"); - assert.ok(block.includes("fetch('/api/alerts'"), "must subscribe to /api/alerts (REST snapshot)"); - assert.ok(STATE_SRC.includes("'/api/stream'"), "state stream rides /api/stream"); - }); - - test("i18n completeness — alerts_* keys exist in BOTH zh and en", () => { - const keys = ["alerts_title", "alerts_empty", "alerts_clear", "alerts_session", - "alerts_level_info", "alerts_level_warn", "alerts_level_error"]; - for (const k of keys) { - const n = (I18N_SRC.match(new RegExp(`\\b${k}\\s*:`, "g")) || []).length; - assert.ok(n >= 2, `i18n key ${k} must be in both dictionaries (found ${n})`); - } - }); - - test("i18n completeness — every previously-missing referenced key now exists in BOTH dictionaries", () => { - // D4: live audit observed raw keys leaking in the workspace picker. - // The full referenced-vs-defined audit lives in this check's sibling - // coverage; here we pin the exact keys the audit found plus the - // systematic sweep result (mode_read was also unreferenced-free). - const keys = ["workspace_sync_tui", "workspace_recents_title", "workspace_browse", - "workspace_loading", "mode_read"]; - for (const k of keys) { - const n = (I18N_SRC.match(new RegExp(`\\b${k}\\s*:`, "g")) || []).length; - assert.ok(n >= 2, `i18n key ${k} must be in both dictionaries (found ${n})`); - } - }); -}); diff --git a/packages/webui/checks/authorize-modal.check.mjs b/packages/webui/checks/authorize-modal.check.mjs deleted file mode 100644 index ba464670..00000000 --- a/packages/webui/checks/authorize-modal.check.mjs +++ /dev/null @@ -1,650 +0,0 @@ -// webui/checks/authorize-modal.check.mjs -// Mocked check for the v2 per-request authorization FRONTEND wiring. -// -// Why this test exists (2026-09-20 webui-manual-audit): the server half -// of the authorize gate was complete — server/lib/authorize.js blocks -// gated actions on a 5-minute fail-closed promise, state-bus.js pushes -// `needs_authorization` / `authorization_decided` control frames, and -// POST /api/auth/decision is routed — but public/ had ZERO wiring. Every -// gated action (delete / export / cross-workspace search / /clear / /new / -// token reset) hung silently for 5 minutes and then declined. A -// regression here means either user deadlock (modal never opens) or -// accidental destruction (an approve firing without a click). -// -// Coverage: -// • state.js stream handlers: needs_authorization enqueues (dedup on -// reconnect replay; malformed frames ignored); authorization_decided -// removes exactly that requestId (unknown ids no-op). -// • submitAuthDecision POST shape: {requestId, approve} with strict -// boolean approve for both true and false; 200/404 clear the queue, -// 500 keeps it for retry. -// • events.js attachModalEvents wiring: Approve click disables BOTH -// buttons while the POST is in flight and posts the head request's -// id; a second click in flight is ignored; network failure surfaces -// inside the modal and re-enables. -// • render.js helpers: formatAuthCountdown math; authActionLabel -// whitelist mapping + unknown fallback; ctx list DOM construction. -// • Static source guards (test/render-static.test.js precedent): -// modal construction is DOM-only (CodeQL js/xss-through-dom), the -// countdown tick is visual-only (cannot decide anything), and the -// i18n dictionaries carry every auth_* key in BOTH zh and en. -// -// How the real modules load under node:test: public/app/state.js pulls -// the whole render/events/util/i18n cluster, which touches DOM globals -// at module-eval time (window / localStorage / document / location / -// WebSocket). -// We install minimal fakes BEFORE the dynamic import — the same globals -// the browser provides for free. No t.mock.module needed: the modules -// under test are the REAL frontend files, imported unmocked. - -import { test, describe, before, beforeEach, after } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { dirname, resolve } from "node:path"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const PLUGIN_ROOT = resolve(__dirname, ".."); -const appPath = (rel) => pathToFileURL(resolve(PLUGIN_ROOT, "public", "app", rel)).href; - -// ---------- minimal DOM / global fakes ---------- - -function makeEl(id) { - return { - id, - children: [], - handlers: {}, - style: {}, - dataset: {}, - textContent: "", - hidden: false, - disabled: false, - className: "", - appendChild(c) { this.children.push(c); return c }, - addEventListener(ev, fn) { - if (!this.handlers[ev]) this.handlers[ev] = []; - this.handlers[ev].push(fn); - }, - setAttribute(k, v) { this.dataset[k] = String(v) }, - querySelector() { return null }, - classList: { add() {}, remove() {}, toggle() {} }, - }; -} - -// Only the auth-modal surface is stubbed; every other getElementById -// (ask-modal, plan-modal, …) returns null — the real modules' module-eval -// and attachModalEvents null-guards tolerate that (v0.5.bx-27 pattern). -const AUTH_MODAL_IDS = [ - "auth-modal", "auth-modal-position", "auth-modal-action", "auth-modal-ctx", - "auth-modal-countdown", "auth-modal-error", "auth-modal-approve", - "auth-modal-deny", -]; -const _els = {}; -const _fetchCalls = []; -let _fetchImpl = null; // per-test override; default 200 {ok:true} - -// Fake WebSocket (decision 20: state.js switched the SSE stream → -// WebSocket /api/stream). Node 24 ships a REAL global WebSocket — -// installGlobals overrides globalThis.WebSocket with this BEFORE -// state.js is imported so connect() never opens a live socket. The -// frontend drives the stream through the onmessage/onclose properties. -class FakeWebSocket { - constructor(url) { - this.url = String(url); - this.readyState = 0; // CONNECTING - this.onopen = null; - this.onmessage = null; - this.onclose = null; - this.onerror = null; - this.sent = []; - this.closed = false; - } - send(data) { this.sent.push(String(data)); } - close() { - this.closed = true; - this.readyState = 3; // CLOSED - if (typeof this.onclose === "function") this.onclose({ code: 1000, reason: "" }); - } - // state.js drives the stream via onmessage; keep addEventListener as - // a harmless recorder for forwards-compatibility. - addEventListener() {} -} - -function installGlobals() { - // Bare browser globals the frontend reads directly: connect() builds - // the ws:// URL from `location` (protocol/host) — Node has no - // `location`, so provide a minimal one (shared with window.location). - const loc = { - protocol: "http:", - host: "127.0.0.1:18090", - hostname: "127.0.0.1", - port: "18090", - origin: "http://127.0.0.1:18090", - search: "", - pathname: "/", - hash: "", - href: "http://127.0.0.1:18090/", - }; - globalThis.location = loc; - globalThis.window = { - location: loc, - history: { replaceState() {} }, - matchMedia: () => ({ matches: false }), - addEventListener() {}, - }; - globalThis.localStorage = (() => { - const m = new Map(); - return { - getItem: (k) => (m.has(k) ? m.get(k) : null), - setItem: (k, v) => m.set(k, String(v)), - removeItem: (k) => m.delete(k), - }; - })(); - globalThis.WebSocket = FakeWebSocket; // 覆盖 Node 自带的真 WebSocket - globalThis.document = { - getElementById: (id) => _els[id] || null, - createElement: (tag) => makeEl(tag), - querySelectorAll: () => [], - addEventListener() {}, - }; - // Node exposes `navigator` as a getter-only global — a plain - // assignment throws (which is exactly what the first run of this - // check hit), so define it as a configurable own property. - try { - Object.defineProperty(globalThis, "navigator", { - value: { onLine: true }, configurable: true, writable: true, - }); - } catch {} - globalThis.fetch = async (url, init) => { - _fetchCalls.push({ url: String(url), init }); - if (_fetchImpl) return _fetchImpl(url, init); - return { ok: true, status: 200, json: async () => ({ ok: true }) }; - }; -} - -// ---------- SUT handles (filled in before()) ---------- - -let stateMod, renderMod, eventsMod, es; - -before(async (t) => { - installGlobals(); - for (const id of AUTH_MODAL_IDS) _els[id] = makeEl(id); - // Real frontend modules, imported AFTER the globals exist. - stateMod = await import(appPath("state.js")); - renderMod = await import(appPath("render.js")); - eventsMod = await import(appPath("events.js")); - // Wire the real attachModalEvents once (its $()/on() null-guards skip - // every missing element; our auth-modal stubs capture the handlers). - eventsMod.attachModalEvents(); - // connect() schedules a 2s /api/refresh setTimeout + a 60s interval - // with REAL timers — under node --test those handles would outlive - // the assertions (and the interval pins the process open). Swap in - // mock timers just for the connect() call so the scheduled refreshes - // simply never fire, then restore the real timers. (Node 26's - // MockTimers surface has no disable() — reset() is the restore.) - t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] }); - try { - stateMod.connect(); - } finally { - t.mock.timers.reset(); - } - es = stateMod.es; - assert.ok(es instanceof FakeWebSocket, "connect() must open our fake WebSocket"); - assert.match(es.url, /\/api\/stream/, `stream must target /api/stream, got: ${es.url}`); -}); - -beforeEach(() => { - _fetchCalls.length = 0; - _fetchImpl = null; - stateMod.getPendingAuthRequests().splice(0); - renderMod.AUTH_MODAL_STATE.decidingRequestId = null; - // Stops any live countdown interval and hides the modal (render.js - // restarts both on the next renderAuthModal). - renderMod.closeAuthModal(); - for (const id of AUTH_MODAL_IDS) { - const el = _els[id]; - el.textContent = ""; - el.disabled = false; - el.hidden = false; - el.style = {}; - el.children.length = 0; - // NOTE: handlers are NOT reset — attachModalEvents binds once in - // before() and the stubs must keep those bindings. - } -}); - -after(() => { - // Belt-and-braces: never leave a countdown interval running. Guarded - // so a failed before() (modules never imported) does not mask the - // original error with a second one. - if (renderMod) renderMod.closeAuthModal(); -}); - -// ---------- helpers ---------- - -let _frameSeq = 0; -// Inject one protocol TEXT frame through the WebSocket's onmessage — -// {v:1, seq, ts, type:"control", payload:{name, data}} where data is -// the event payload as a JSON STRING (the same bytes the old SSE -// `data:` line carried). fireRaw takes the string as-is so malformed -// payloads stay malformed. -function fireRaw(name, dataStr) { - assert.equal( - typeof es.onmessage, - "function", - "state.js must register ws.onmessage for control-frame dispatch", - ); - es.onmessage({ - data: JSON.stringify({ - v: 1, - seq: ++_frameSeq, - ts: Date.now(), - type: "control", - payload: { name, data: dataStr }, - }), - }); -} -function fireControl(name, data) { - fireRaw(name, JSON.stringify(data)); -} -function pushAuthFrame({ requestId, action = "slash.clear", ctx = {}, expiresAt = Date.now() + 300_000 }) { - fireControl("needs_authorization", { requestId, action, ctx, expiresAt }); -} -function fireDecided(requestId, approved = true, decidedBy = "user") { - fireControl("authorization_decided", { requestId, approved, decidedBy }); -} - -// ============================================================ -// state.js stream handlers — queue add/remove -// ============================================================ - -describe("state.js stream handlers — pending queue", () => { - test("needs_authorization enqueues the parsed frame and opens the modal", () => { - pushAuthFrame({ - requestId: "rid-1", - action: "session.delete", - ctx: { cid: "c1", targetSessionId: "mvs_abc", matchKind: "mcodeSessionId", isOrphan: false, chatLen: 42 }, - }); - const q = stateMod.getPendingAuthRequests(); - assert.equal(q.length, 1); - assert.equal(q[0].requestId, "rid-1"); - assert.equal(q[0].action, "session.delete"); - assert.equal(q[0].ctx.targetSessionId, "mvs_abc"); - assert.ok(q[0].expiresAt > Date.now(), "expiresAt must survive parsing"); - // modal opened on the head request - assert.equal(_els["auth-modal"].style.display, "flex"); - assert.equal(_els["auth-modal-action"].textContent, "Delete session"); - assert.match(_els["auth-modal-countdown"].textContent, /^\d{2}:\d{2}$/); - // single pending request → no position indicator - assert.equal(_els["auth-modal-position"].textContent, ""); - }); - - test("duplicate frame (resume replay) does not double-queue", () => { - pushAuthFrame({ requestId: "rid-dup", action: "token.reset" }); - pushAuthFrame({ requestId: "rid-dup", action: "token.reset" }); - assert.equal(stateMod.getPendingAuthRequests().length, 1); - }); - - test("multiple pending: queue order preserved, position indicator 1/2", () => { - pushAuthFrame({ requestId: "rid-a", action: "session.delete" }); - pushAuthFrame({ requestId: "rid-b", action: "token.reset" }); - const q = stateMod.getPendingAuthRequests(); - assert.equal(q.length, 2); - assert.equal(q[0].requestId, "rid-a", "arrival order — first in, first displayed"); - // head still rid-a; indicator shows 1/2 (default en dictionary) - assert.equal(_els["auth-modal-action"].textContent, "Delete session"); - assert.equal(_els["auth-modal-position"].textContent, "pending 1/2"); - }); - - test("authorization_decided removes exactly that request and closes the empty modal", () => { - pushAuthFrame({ requestId: "rid-close" }); - fireDecided("rid-close"); - assert.equal(stateMod.getPendingAuthRequests().length, 0); - assert.equal(_els["auth-modal"].style.display, "none"); - assert.equal(renderMod.AUTH_MODAL_STATE.countdownTimer, null, "countdown interval must be cleaned up on close"); - }); - - test("authorization_decided (timeout resolution) advances to the next queued request", () => { - pushAuthFrame({ requestId: "rid-t1", action: "session.delete" }); - pushAuthFrame({ requestId: "rid-t2", action: "token.reset" }); - fireDecided("rid-t1", false, "timeout"); - const q = stateMod.getPendingAuthRequests(); - assert.equal(q.length, 1); - assert.equal(q[0].requestId, "rid-t2"); - assert.equal(_els["auth-modal"].style.display, "flex", "modal stays open for the next request"); - assert.equal(_els["auth-modal-action"].textContent, "Reset access token"); - assert.equal(_els["auth-modal-position"].textContent, ""); - }); - - test("authorization_decided for an unknown id is a no-op", () => { - pushAuthFrame({ requestId: "rid-keep" }); - fireDecided("no-such-id"); - assert.equal(stateMod.getPendingAuthRequests().length, 1); - assert.equal(_els["auth-modal"].style.display, "flex"); - }); - - test("malformed frames are ignored without throwing", () => { - pushAuthFrame({ requestId: "rid-m" }); - fireRaw("needs_authorization", "not json at all"); - fireRaw("authorization_decided", ""); - fireRaw("needs_authorization", JSON.stringify({ noRequestId: true })); - const q = stateMod.getPendingAuthRequests(); - assert.equal(q.length, 1); - assert.equal(q[0].requestId, "rid-m"); - }); -}); - -// ============================================================ -// submitAuthDecision — POST /api/auth/decision body shape -// ============================================================ - -describe("submitAuthDecision — decision POST contract", () => { - test("approve:true posts strict-boolean JSON and clears the queue on 200", async () => { - pushAuthFrame({ requestId: "rid-ok" }); - const r = await stateMod.submitAuthDecision("rid-ok", true); - assert.equal(r.ok, true); - assert.equal(_fetchCalls.length, 1); - const call = _fetchCalls[0]; - assert.ok(call.url.startsWith("/api/auth/decision"), `unexpected url ${call.url}`); - assert.equal(call.init.method, "POST"); - assert.equal(call.init.headers["Content-Type"], "application/json"); - const body = JSON.parse(call.init.body); - assert.deepEqual(Object.keys(body).sort(), ["approve", "requestId"]); - assert.equal(body.requestId, "rid-ok"); - assert.equal(body.approve, true); - assert.strictEqual(body.approve, true, "approve must be the literal boolean true"); - assert.equal(stateMod.getPendingAuthRequests().length, 0, "200 must remove the request locally"); - }); - - test("approve:false posts approve===false — a deny is a real decision, not an absent one", async () => { - pushAuthFrame({ requestId: "rid-no" }); - await stateMod.submitAuthDecision("rid-no", false); - assert.equal(_fetchCalls.length, 1); - const body = JSON.parse(_fetchCalls[0].init.body); - assert.equal(body.requestId, "rid-no"); - assert.strictEqual(body.approve, false); - assert.equal(stateMod.getPendingAuthRequests().length, 0); - }); - - test("404 (already decided elsewhere / server timeout evicted) also clears the queue", async () => { - _fetchImpl = () => ({ - ok: false, status: 404, - json: async () => ({ ok: false, error: "no pending request with that id" }), - }); - pushAuthFrame({ requestId: "rid-404" }); - const r = await stateMod.submitAuthDecision("rid-404", true); - assert.equal(r.status, 404); - assert.equal(stateMod.getPendingAuthRequests().length, 0, "the request is finished — modal must not hang on a lost SSE frame"); - }); - - test("500 keeps the request queued so the user can retry", async () => { - _fetchImpl = () => ({ ok: false, status: 500, json: async () => ({ ok: false, error: "boom" }) }); - pushAuthFrame({ requestId: "rid-500" }); - const r = await stateMod.submitAuthDecision("rid-500", true); - assert.equal(r.status, 500); - assert.equal(stateMod.getPendingAuthRequests().length, 1); - }); - - test("network error rejects (caller surfaces it) and keeps the request", async () => { - _fetchImpl = async () => { throw new Error("network down") }; - pushAuthFrame({ requestId: "rid-net0" }); - await assert.rejects(() => stateMod.submitAuthDecision("rid-net0", true), /network down/); - assert.equal(stateMod.getPendingAuthRequests().length, 1); - }); -}); - -// ============================================================ -// events.js attachModalEvents — Approve/Deny wiring -// ============================================================ - -describe("events.js attachModalEvents — Approve/Deny wiring", () => { - test("Approve click posts the head request's decision and disables both buttons in flight", async () => { - pushAuthFrame({ requestId: "rid-click" }); - const approve = _els["auth-modal-approve"]; - const deny = _els["auth-modal-deny"]; - let release; - _fetchImpl = () => new Promise((res) => { - release = () => res({ ok: true, status: 200, json: async () => ({ ok: true }) }); - }); - const p = approve.handlers.click[0](); - // decideAuth ran synchronously up to the awaited fetch: buttons are - // already disabled and exactly one POST is in flight. - assert.equal(approve.disabled, true); - assert.equal(deny.disabled, true); - assert.equal(_fetchCalls.length, 1); - const body = JSON.parse(_fetchCalls[0].init.body); - assert.equal(body.requestId, "rid-click"); - assert.equal(body.approve, true); - release(); - await p; - assert.equal(stateMod.getPendingAuthRequests().length, 0); - }); - - test("a second click while a decision is in flight is ignored (one decision per request)", async () => { - pushAuthFrame({ requestId: "rid-2x" }); - const approve = _els["auth-modal-approve"]; - const deny = _els["auth-modal-deny"]; - let release; - _fetchImpl = () => new Promise((res) => { - release = () => res({ ok: true, status: 200, json: async () => ({ ok: true }) }); - }); - const p1 = approve.handlers.click[0](); - const p2 = deny.handlers.click[0](); // must be a no-op — POST already in flight - release(); - await p1; - await p2; - assert.equal(_fetchCalls.length, 1, "exactly one decision POST per request"); - }); - - test("after a resolved request, the next queued request can still be decided (no stale decidingRequestId)", async () => { - pushAuthFrame({ requestId: "rid-b1", action: "session.delete" }); - pushAuthFrame({ requestId: "rid-b2", action: "token.reset" }); - const approve = _els["auth-modal-approve"]; - // decide rid-b1 successfully - await approve.handlers.click[0](); - assert.equal(_fetchCalls.length, 1); - assert.equal(JSON.parse(_fetchCalls[0].init.body).requestId, "rid-b1"); - // head is now rid-b2 — the buttons must be usable again (a stale - // decidingRequestId from rid-b1 must not block rid-b2's decision) - assert.equal(approve.disabled, false); - await approve.handlers.click[0](); - assert.equal(_fetchCalls.length, 2); - assert.equal(JSON.parse(_fetchCalls[1].init.body).requestId, "rid-b2"); - assert.equal(stateMod.getPendingAuthRequests().length, 0); - }); - - test("Deny click posts approve:false", async () => { - pushAuthFrame({ requestId: "rid-deny" }); - const deny = _els["auth-modal-deny"]; - await deny.handlers.click[0](); - assert.equal(_fetchCalls.length, 1); - const body = JSON.parse(_fetchCalls[0].init.body); - assert.equal(body.requestId, "rid-deny"); - assert.strictEqual(body.approve, false); - }); - - test("network failure shows the error inside the modal and re-enables both buttons", async () => { - pushAuthFrame({ requestId: "rid-fail" }); - const approve = _els["auth-modal-approve"]; - const deny = _els["auth-modal-deny"]; - const err = _els["auth-modal-error"]; - _fetchImpl = async () => { throw new Error("network down") }; - await approve.handlers.click[0](); - assert.equal(approve.disabled, false, "failed POST must re-enable for retry"); - assert.equal(deny.disabled, false); - assert.equal(err.hidden, false, "error must be visible inside the modal"); - assert.match(err.textContent, /network down/); - assert.equal(stateMod.getPendingAuthRequests().length, 1, "failed POST keeps the request queued"); - }); - - test("re-render while a decision is in flight does NOT re-enable the buttons", async () => { - pushAuthFrame({ requestId: "rid-rr" }); - pushAuthFrame({ requestId: "rid-rr2" }); - const approve = _els["auth-modal-approve"]; - let release; - _fetchImpl = () => new Promise((res) => { - release = () => res({ ok: true, status: 200, json: async () => ({ ok: true }) }); - }); - const p = approve.handlers.click[0](); - // some unrelated state push re-renders the modal mid-flight - renderMod.renderAuthModal(); - assert.equal(approve.disabled, true, "decidingRequestId must survive re-renders"); - release(); - await p; - }); -}); - -// ============================================================ -// render.js helpers -// ============================================================ - -describe("render.js helpers", () => { - test("formatAuthCountdown — mm:ss, ceil, clamped at zero", () => { - assert.equal(renderMod.formatAuthCountdown(0), "00:00"); - assert.equal(renderMod.formatAuthCountdown(-5000), "00:00"); - assert.equal(renderMod.formatAuthCountdown(1), "00:01"); - assert.equal(renderMod.formatAuthCountdown(2999), "00:03"); // ceil, not floor - assert.equal(renderMod.formatAuthCountdown(65000), "01:05"); - assert.equal(renderMod.formatAuthCountdown(300_000), "05:00"); - assert.equal(renderMod.formatAuthCountdown("junk"), "00:00"); - assert.equal(renderMod.formatAuthCountdown(undefined), "00:00"); - }); - - test("authActionLabel maps all 8 whitelist actions; unknown falls back to raw", () => { - const whitelist = [ - "session.delete", "sessions.cleanup-orphans", "session.cleanup-all", - "session.export", "session.search", "token.reset", "slash.clear", - "startup.cleanup", - ]; - for (const a of whitelist) { - const s = renderMod.authActionLabel(a); - assert.ok(s && s !== a, `action ${a} should map to a human label, got ${JSON.stringify(s)}`); - } - assert.equal(renderMod.authActionLabel("totally.unknown"), "totally.unknown"); - assert.equal(renderMod.authActionLabel(""), ""); - }); - - test("ctx list: known fields labeled, cid skipped, unknown fields generic — DOM construction", () => { - pushAuthFrame({ - requestId: "rid-ctx", - action: "session.delete", - ctx: { cid: "c1", targetSessionId: "mvs_abc", isOrphan: true, chatLen: 7, weirdField: "" }, - }); - const list = _els["auth-modal-ctx"].children[0]; - assert.ok(list, "ctx list must be appended to #auth-modal-ctx"); - assert.equal(list.className, "auth-modal-ctx"); - const rows = list.children; - assert.equal(rows.length, 4, "cid must be skipped as a routing field"); - const kv = rows.map((r) => [r.children[0].textContent, r.children[1].textContent]); - assert.deepEqual(kv, [ - ["target session", "mvs_abc"], - ["orphan", "true"], - ["chat lines", "7"], - ["weirdField", ""], // generic fallback, raw key + textContent value - ]); - }); - - test("ctx list: object/array values are JSON-stringified (orphanIds)", () => { - pushAuthFrame({ - requestId: "rid-arr", - action: "sessions.cleanup-orphans", - ctx: { orphanCount: 2, orphanIds: ["a", "b"] }, - }); - const list = _els["auth-modal-ctx"].children[0]; - const kv = list.children.map((r) => [r.children[0].textContent, r.children[1].textContent]); - assert.deepEqual(kv, [ - ["orphan count", "2"], - ["orphan ids", '["a","b"]'], - ]); - }); -}); - -// ============================================================ -// Static source guards (test/render-static.test.js precedent) -// ============================================================ - -describe("static source guards", () => { - const RENDER_SRC = readFileSync(resolve(PLUGIN_ROOT, "public", "app", "render.js"), "utf8"); - const EVENTS_SRC = readFileSync(resolve(PLUGIN_ROOT, "public", "app", "events.js"), "utf8"); - const INDEX_SRC = readFileSync(resolve(PLUGIN_ROOT, "public", "index.html"), "utf8"); - const I18N_SRC = readFileSync(resolve(PLUGIN_ROOT, "public", "app", "i18n.js"), "utf8"); - - function slice(src, startMarker, endMarker) { - const s = src.indexOf(startMarker); - assert.ok(s !== -1, `start marker not found: ${startMarker}`); - const e = src.indexOf(endMarker, s); - assert.ok(e !== -1, `end marker not found after start: ${endMarker}`); - return src.slice(s, e + endMarker.length); - } - - test("authorize-modal section builds DOM only — zero innerHTML (CodeQL js/xss-through-dom)", () => { - const block = slice( - RENDER_SRC, - "// v2 (2026-09-20 webui-manual-audit): authorize modal — begin auth-modal", - "// v2 (2026-09-20 webui-manual-audit): authorize modal — end auth-modal", - ); - assert.equal( - /\.innerHTML\s*=/.test(block), false, - "authorize modal must not assign innerHTML — requestId/action/ctx are untrusted SSE wire data", - ); - assert.match(block, /createElement\(\s*['"]div['"]\s*\)/); - assert.match(block, /createElement\(\s*['"]span['"]\s*\)/); - assert.match(block, /\.textContent\s*=/); - }); - - test("countdown tick is visual-only — it cannot decide anything", () => { - // _startAuthCountdown sits between _stopAuthCountdown and - // buildAuthCtxList in render.js (declaration order), so slice to - // the next function to capture the whole ticking block. - const block = slice(RENDER_SRC, "function _startAuthCountdown(", "function buildAuthCtxList("); - assert.ok(!block.includes("submitAuthDecision"), "countdown must never post a decision"); - assert.ok(!block.includes("fetch("), "countdown must never fetch"); - assert.ok(!/approve\s*[:=]\s*true/.test(block), "countdown must never approve"); - }); - - test("events.js wiring has no timer-driven decision (never auto-approves)", () => { - const block = slice( - EVENTS_SRC, - "v2 (2026-09-20 webui-manual-audit): authorize modal Approve/Deny", - "if (authApproveBtn) authApproveBtn.addEventListener", - ); - assert.ok(!/setInterval|setTimeout/.test(block), "decisions may only come from a click, never a timer"); - }); - - test("index.html carries the #auth-modal mount with Approve/Deny and NO dismiss affordance", () => { - const block = slice( - INDEX_SRC, - '>B: 200 {ok:true} (ack only — everything else is the event stream) + C-->>B: 200 {ok:true} (ack only — everything else is SSE) ``` The stream that follows — every engine event becomes a chat line, every -chat mutation becomes a state snapshot on the event stream: +chat mutation becomes an SSE state snapshot: ```mermaid sequenceDiagram @@ -182,7 +186,7 @@ sequenceDiagram A->>M: {kind:'thought', text} M->>M: streamUpdateLine(cs.chat, "▲", text) M->>S: pushStateFor(cid) (60Hz coalesced) - S-->>B: WS {type:'state', chat:[...], running:{active:true,tps}} + S-->>B: SSE {type:'state', chat:[...], running:{active:true,tps}} B->>B: thinking block (escaped text, collapsible) end loop per tool call (incl. MCP tools & skill-spawned tools) @@ -271,7 +275,7 @@ flowchart TD F{"clicked id is mvs_…?"} G["switch: find-or-create overlay
(id = mvs_…, idempotent)"] H["transcript backfill from
runtime SQLite (≤400 lines / ≤200KB)"] - I["bind cs: sessionId / mcodeSessionId / chat
→ pushStateFor (event stream)"] + I["bind cs: sessionId / mcodeSessionId / chat
→ pushStateFor (SSE)"] end subgraph STORES["stores"] @@ -329,30 +333,37 @@ Each `server/lib/*.js` file exports a small set of named functions. No file reaches into another's internals. The notable contracts: ### `config.js` -- Exports frozen-ish constants: `MCODE_ROOT`, `MCODE_CMD`, `PORT`, `HOST`, - `TOKEN`, `DEFAULT_MODEL`, `DEFAULT_TIMEOUT`, `DEFAULT_MAX_STEPS`, +- Exports frozen-ish constants: `PACKAGE_ROOT` (alias for `WEBUI_ROOT`), + `WEBUI_DATA_DIR`, `MCODE_CMD`, `PORT`, `PORT_PINNED`, `HOST`, `TOKEN`, + `TOKEN_STDOUT`, `DEFAULT_MODEL`, `DEFAULT_TIMEOUT`, `DEFAULT_MAX_STEPS`, `MAX_CONCURRENT`, `UPLOAD_DIR`, `SESSIONS_DB`, `MCODE_RUNTIME_DB`, - `MAVIS_DATA_DIR`, `MAVIS_DB_PATH`, `SQLITE3_BIN`, `DEFAULT_WORKSPACE`. -- Exports functions: `getPlatformFallbackPaths`, `detectSqlite3Bin`, - `detectTuiCwd` (re-export), `installGlobalErrorHandlers`. + `MAVIS_DATA_DIR`, `MAVIS_DB_PATH`, `SQLITE3_BIN`, `DEFAULT_WORKSPACE`, + `PROMPT_IDLE_TIMEOUT_MS`, `RATE_LIMIT_PER_MIN`, `RATE_LIMIT_BURST`, + `MCODE_WEBUI_UPLOAD_DIR`, `MCODE_WEBUI_SETTINGS_PATH`, + `MCODE_BETTER_SQLITE3`, `DEBUG_INJECT`. +- Exports functions: `getServingPort`, `setServingPort`, `resolveBindHost`, + `getPlatformFallbackPaths`, `detectSqlite3Bin`, `detectTuiCwd` + (re-export), `installGlobalErrorHandlers`. - Reads `process.env.*` exactly once at module load. No per-request - re-reading. + re-reading (port fallback is an exception — `setServingPort` updates + the live port after boot). +- Data-dir precedence: `MINIMAX_DATA_DIR` > `MAVIS_DATA_DIR` > `~/.minimax` + (one resolver shared with `MAVIS_DB_PATH`, which is the runtime SQLite). - `installGlobalErrorHandlers()` writes uncaught exceptions to - `.server.err` so they survive a process restart. + `.server.err` under `WEBUI_DATA_DIR` so they survive a process restart. ### `state-bus.js` The chokepoint. Exports: | Function | Purpose | |---|---| -| `getClient(cid)` | Returns the per-cid `clientState` built by `makeClientState()` (`version`, `workspace`, `model`, `sessionId`, `mcodeSessionId`, `chat`, `sessions`, `context`, `usage`, `permissions`, `running`, `plan`, `ask`, `todo`, `goal` …). Lazily creates on first call; there is no `sse` field — the per-cid live channel is the `/api/stream` subscription on the event bus. | -| `pushStateFor(cid, opts)` | Build a full snapshot for the cid — the `clientState` fields plus injected `sessions`, settings and quota fields (`opts` carries `lanBroadcast` / `mcodeSessions` overrides) — and publish it straight to the event bus as a `state.snapshot` event; `cid === "__broadcast__"` fans out to every subscribed cid. | -| `pushOnlineCount(lanBroadcast)` | Set `onlineCount` to the number of event-stream subscribers (`getSubscribedCids().length`) and emit a `state.snapshot` to every subscribed cid. Called on `/api/stream` connect/disconnect. | +| `getClient(cid)` | Returns the `clientState` object: `state`, `sse`, `activeChild`, `chatHistory`, `requestSeq`. Lazily creates on first call. | +| `pushStateFor(cid, opts)` | Build a normalized `state` object and write it to `clientState.state`. Broadcasts to the SSE channel unless `opts.silent`. | +| `pushOnlineCount(lanBroadcast)` | Count `sseByCid.size` and broadcast to all clients. Called on connect/disconnect. | +| `SSE_HEADERS` | Standard headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`, `X-Accel-Buffering: no`. | -The snapshot payload shape is documented in § 4 below. Snapshots are -built on the fly by `pushStateFor` (`clientState` fields + injected -sessions / settings / quota fields); the rest of the codebase reads -the `clientState` fields themselves. +The `state` payload is documented in § 5 below. The `clientState.state` +object is the **only** thing the rest of the codebase reads from. ### `acp-client.js` Wraps mcode's JSON-RPC-over-stdio protocol. Exports: @@ -368,27 +379,53 @@ Wraps mcode's JSON-RPC-over-stdio protocol. Exports: `session/commands`. ### `mcode-rpc.js` -The shim for methods mcode 0.1.5 does not implement: + +The acp-side wrapper. Each public function (`setMode`, +`setConfigOption`, `cancelSession`, `loadSession`, `activateSession`, +`listSessions`, `getAccountStatus`, …) dispatches through +`clientForCid(cid, requireLive)`: + + - if a `cid` is supplied, the cid's registered active child + (the per-prompt `McodeAcpClient`) is preferred — that subprocess + is the one whose `sessions` map holds the in-flight session; + - `requireLive: true` (used by `cancelSession` / `setConfigOption`) + returns `null` rather than falling back to the singleton when no + active child is registered, because silent fallback would mask the + dispatch bug that previous PRs reintroduced; + - everything else falls back to the singleton, which keeps the + commands-probe and session-list paths working without a cid. + +The capability table webui advertises to itself: ```js -const UNSUPPORTED = new Set([ - 'session/set_mode', - 'session/set_config_option', - 'session/cancel', - 'session/activate', 'session/fork', 'session/resume', 'session/delete', - 'session/request_permission', 'session/subscribe', -]) +export const MCODE_ACP_CAPABILITIES = { + set_mode: true, + set_config_option: true, + cancel: true, + activate: true, + fork: true, + resume: true, + // session/delete registers on the engine but no handler exists, + // which is why deletes go through SQL on the local_runtime_* + // tables (see sqlite-resolver.js). + delete: false, + load: true, + close: true, + list: true, + new: true, + prompt: true, +} ``` -`callRpc(method, params)` returns -`{ok:false, code:'unsupported', error:'…'}` synchronously when the -method is unsupported. The caller decides what to do — usually a toast -on the client. +`callRpc(method, params)` returns `{ok:false, code:'unsupported', +error:'…'}` when the engine answers with `-32601 Method not found` +(or the equivalent in the jsonrpc envelope). The caller decides what +to do — usually a toast on the client. ### `mcode-acp.js` vs `mcode-exec.js` Two transports with a shared shape. The transport layer is selected -by `mcode-rpc.js` based on `mcode version >= 0.1.4` and the per-request -`/exec` opt-in. +by `mcode-rpc.js` based on the engine's reported version (from the +`initialize` reply's `agentInfo`) and the per-request `/exec` opt-in. Both expose: - `runMcode(content, opts)` → `AsyncGenerator` @@ -399,10 +436,10 @@ Both expose: `state`, `chat`, `delta`, `tool`, `permission`, `plan`, `ask`, `exec`, `usage`. See § 5. -## 4. The state snapshot payload +## 4. The `clientState.state` payload -This is the shape every state snapshot on the event stream contains. -The webui mirrors it 1:1 into the `state` JS variable. +This is the shape every SSE `state` event contains. The webui mirrors +it 1:1 into the `state` JS variable. ```ts { @@ -439,7 +476,7 @@ The webui mirrors it 1:1 into the `state` JS variable. cwd: string, updatedAt: number }>, mcodeSessionId?: string, // currently-active mcode session - context?: { // updated by delta accumulation + context?: { // updated by SSE delta accumulation used: number, // tokens used (per-turn) percent: number, // 0..100 cacheRead: number, // per-turn cache reads @@ -462,7 +499,7 @@ The webui mirrors it 1:1 into the `state` JS variable. todo?: Array<{ content: string, status: 'pending'|'in_progress'|'done' }>, lanBroadcast: boolean, // mirrors /api/settings onlineCount: number, // from pushOnlineCount - // 🆕 v1.0.1 — settings surface pushed over event-stream state updates + // 🆕 v1.0.1 — settings surface pushed over SSE state updates readOnly: boolean, // read-only mode (server gate blocks remote POST/DELETE on /api/*) tokenEnabled: boolean, // token auth master switch (default true) currentToken: string, // 32-hex auto-generated token; "" after tokenAcknowledged=true @@ -475,51 +512,32 @@ The webui **does not** hold additional state outside this object. Any UI panel that needs data reads it from `state` and reacts to `state` changes via `render()`. -## 5. Event schema (WebSocket event stream) - -Two event types — `state` (the standard state push) and a 🆕 -v1.0.1 named event `auth.token_rotated` that fires only when the -token changes. +## 5. SSE event schema -> **Channel note (decision 20).** SSE is removed. These events ride -> `GET /api/stream` as `state.snapshot` frames (payload = the §4 state -> object) and `control` frames (`{v:1, seq, ts, type:"control", -> payload:{name, data}}`) — see [API.md `GET /api/stream`](API.md). -> The `event:` / `data:` lines below are the pre-decision-20 encoding, -> retained as the canonical event-name → payload map. +Two channels, one data frame type. `/api/events` is the per-CID state stream +and carries `state` plus four named events; `/api/alerts` is a global anomaly +stream (see §5.1). ``` event: state -data: {"version":"0.1.3","running":{"active":true,…},…} - -event: chat -data: {"lines":[{"role":"user","content":"…"}]} - -event: delta -data: {"sessionId":"mvs_…","text":"hello","isPartial":true} - -event: tool -data: {"name":"Bash","input":{…},"output":"…","status":"ok"|"err"|"running"} - -event: permission -data: {"id":"perm_…","tool":"Bash","input":{…},"options":["ask","auto","full"]} - -event: plan -data: {"title":"…","summary":"…","options":[…],"totalLines":N,"summaryLines":N} - -event: ask -data: {"questions":[{"header":"…","question":"…","options":[…], "multiSelect":false}]} +data: {"version":"1.0","running":{"active":true},"chat":["› …"],"…":…} -event: exec -data: {"status":"ok"|"err"|"aborted","durationMs":N,"errorMessage"?:string} - -event: usage -data: {"remaining":N,"resetAt":N,…} - -event: online -data: {"count":N,"lanBroadcast":true} +event: auth.token_rotated +data: // raw string, NOT JSON-wrapped ``` +That is the whole schema on this channel. There is **one** data frame type +(`state`) plus four named events that the server emits out of band: +`auth.token_rotated`, `token.first_run`, `needs_authorization` and +`authorization_decided`. The separate `chat` / `delta` / `tool` / +`permission` / `plan` / `ask` / `exec` / `usage` / `online` frames that an +earlier revision of this section listed were never emitted by this server — +those shapes travel as *fields inside* the single `state` snapshot +(`state.chat`, `state.plan`, `state.ask`, `state.context`, `state.usage`, +`state.onlineCount`). A client parses one payload and renders from it; it does +not switch on an event name. The one exception is the second channel, +`/api/alerts`, which does carry named events — see §5.1. + 🆕 **v1.0.1** — a separate named event for live token rotation: ``` @@ -535,59 +553,140 @@ and the live `HEADERS.Authorization` object **in place** — subsequent Clients that were offline when the event fired will get `401` on their next request; they need to be re-sent the new URL manually. -The payload's `data` field is the **raw token string**, not a -JSON-encoded string — it's obvious in devtools that this is sensitive -material, and double-encoding would not add any value (and would -obscure the token when copy-pasted from network logs). +The body is **raw text**, not JSON-encoded — it's obvious in devtools +that this is sensitive material, and `JSON.stringify` would not add +any value (and would obscure the token when copy-pasted from +network logs). The webui treats each event as an idempotent update; replaying the -same event is safe. The event stream keeps a per-cid ring buffer: on -reconnect the client resumes from `lastSeq`, and when the buffer has -underrun the server replays the latest `state.snapshot` as the -baseline (the first-connect baseline comes from `GET /api/state`). +same event is safe. The server uses an at-most-once delivery model +(SSE drops on disconnect → no retry), which the client handles by +fetching `/api/state` on reconnect. ## 6. Frontend topology ``` -public/index.html (markup only, no inline - - - - - - - - - - - - - -
- - - - - - - diff --git a/packages/webui/public/lib/marked.min.js b/packages/webui/public/lib/marked.min.js deleted file mode 100644 index a91afe79..00000000 --- a/packages/webui/public/lib/marked.min.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * marked v12.0.2 - a markdown parser - * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) - * https://github.com/markedjs/marked - */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&","<":"<",">":">",'"':""","'":"'"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function p(e){return e.replace(h,((e,t)=>"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""))}const u=/(^|[^\[])\^/g;function k(e,t){let n="string"==typeof e?e:e.source;t=t||"";const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(u,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}function g(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch(e){return null}return e}const f={exec:()=>null};function d(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:x(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t){const n=e.match(/^(\s+)(?:```)/);if(null===n)return t;const s=n[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[n]=t;return n.length>=s.length?e.slice(s.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=x(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=t[0].replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,"\n $1");e=x(e.replace(/^ *>[ \t]?/gm,""),"\n");const n=this.lexer.state.top;this.lexer.state.top=!0;const s=this.lexer.blockTokens(e);return this.lexer.state.top=n,{type:"blockquote",raw:t[0],tokens:s,text:e}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim();const s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);let l="",o="",a=!1;for(;e;){let n=!1;if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;l=t[0],e=e.substring(l.length);let s=t[2].split("\n",1)[0].replace(/^\t+/,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=0;this.options.pedantic?(h=2,o=s.trimStart()):(h=t[2].search(/[^ ]/),h=h>4?1:h,o=s.slice(h),h+=t[1].length);let p=!1;if(!s&&/^ *$/.test(c)&&(l+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),n=new RegExp(`^ {0,${Math.min(3,h-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),r=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:\`\`\`|~~~)`),i=new RegExp(`^ {0,${Math.min(3,h-1)}}#`);for(;e;){const a=e.split("\n",1)[0];if(c=a,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),r.test(c))break;if(i.test(c))break;if(t.test(c))break;if(n.test(e))break;if(c.search(/[^ ]/)>=h||!c.trim())o+="\n"+c.slice(h);else{if(p)break;if(s.search(/[^ ]/)>=4)break;if(r.test(s))break;if(i.test(s))break;if(n.test(s))break;o+="\n"+c}p||c.trim()||(p=!0),l+=a+"\n",e=e.substring(a.length+1),s=c.slice(h)}}r.loose||(a?r.loose=!0:/\n *\n *$/.test(l)&&(a=!0));let u,k=null;this.options.gfm&&(k=/^\[[ xX]\] /.exec(o),k&&(u="[ ] "!==k[0],o=o.replace(/^\[[ xX]\] +/,""))),r.items.push({type:"list_item",raw:l,task:!!k,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=l}r.items[r.items.length-1].raw=l.trimEnd(),r.items[r.items.length-1].text=o.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>/\n.*\n/.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e$/,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",s=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:e,raw:t[0],href:n,title:s}}}table(e){const t=this.rules.block.table.exec(e);if(!t)return;if(!/[:|]/.test(t[2]))return;const n=d(t[1]),s=t[2].replace(/^\||\| *$/g,"").split("|"),r=t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[],i={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===s.length){for(const e of s)/^ *-+: *$/.test(e)?i.align.push("right"):/^ *:-+: *$/.test(e)?i.align.push("center"):/^ *:-+ *$/.test(e)?i.align.push("left"):i.align.push(null);for(const e of n)i.header.push({text:e,tokens:this.lexer.inline(e)});for(const e of r)i.rows.push(d(e,i.header.length).map((e=>({text:e,tokens:this.lexer.inline(e)}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:c(t[1])}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&/^/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&/^$/.test(e))return;const t=x(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),/^$/.test(e)?n.slice(1):n.slice(1,-1)),b(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(/\s+/g," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return b(n,e,n[0],this.lexer)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(/[\p{L}\p{N}]/u))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(/\n/g," ");const n=/[^ ]/.test(e),s=/^ /.test(e)&&/ $/.test(e);return n&&s&&(e=e.substring(1,e.length-1)),e=c(e,!0),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=c(t[1]),n="mailto:"+e):(e=c(t[1]),n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=c(t[0]),n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=c(t[0]),n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){let e;return e=this.lexer.state.inRawBlock?t[0]:c(t[0]),{type:"text",raw:t[0],text:e}}}}const m=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,y=/(?:[*+-]|\d{1,9}[.)])/,$=k(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,y).replace(/blockCode/g,/ {4}/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),z=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,T=/(?!\s*\])(?:\\.|[^\[\]\\])+/,R=k(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/).replace("label",T).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),_=k(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,y).getRegex(),A="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",S=/|$))/,I=k("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))","i").replace("comment",S).replace("tag",A).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),E=k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),q={blockquote:k(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",E).getRegex(),code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,def:R,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:m,html:I,lheading:$,list:_,newline:/^(?: *(?:\n|$))+/,paragraph:E,table:f,text:/^[^\n]+/},Z=k("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),L={...q,table:Z,paragraph:k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",Z).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex()},P={...q,html:k("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",S).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:f,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:k(z).replace("hr",m).replace("heading"," *#{1,6} *[^\n]").replace("lheading",$).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Q=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,v=/^( {2,}|\\)\n(?!\s*$)/,B="\\p{P}\\p{S}",C=k(/^((?![*_])[\spunctuation])/,"u").replace(/punctuation/g,B).getRegex(),M=k(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/,"u").replace(/punct/g,B).getRegex(),O=k("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)[punct](\\*+)(?=[\\s]|$)|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])|[\\s](\\*+)(?!\\*)(?=[punct])|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])|[^punct\\s](\\*+)(?=[^punct\\s])","gu").replace(/punct/g,B).getRegex(),D=k("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\\s]|$)|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)|(?!_)[punct\\s](_+)(?=[^punct\\s])|[\\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])","gu").replace(/punct/g,B).getRegex(),j=k(/\\([punct])/,"gu").replace(/punct/g,B).getRegex(),H=k(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),U=k(S).replace("(?:--\x3e|$)","--\x3e").getRegex(),X=k("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",U).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),F=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,N=k(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",F).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),G=k(/^!?\[(label)\]\[(ref)\]/).replace("label",F).replace("ref",T).getRegex(),J=k(/^!?\[(ref)\](?:\[\])?/).replace("ref",T).getRegex(),K={_backpedal:f,anyPunctuation:j,autolink:H,blockSkip:/\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g,br:v,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:f,emStrongLDelim:M,emStrongRDelimAst:O,emStrongRDelimUnd:D,escape:Q,link:N,nolink:J,punctuation:C,reflink:G,reflinkSearch:k("reflink|nolink(?!\\()","g").replace("reflink",G).replace("nolink",J).getRegex(),tag:X,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\t+" ".repeat(n.length)));e;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.space(e))e=e.substring(n.raw.length),1===n.raw.length&&t.length>0?t[t.length-1].raw+="\n":t.push(n);else if(n=this.tokenizer.code(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?t.push(n):(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.fences(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.heading(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.hr(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.blockquote(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.list(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.html(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.def(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?this.tokens.links[n.tag]||(this.tokens.links[n.tag]={href:n.href,title:n.title}):(s.raw+="\n"+n.raw,s.text+="\n"+n.raw,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.table(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.lheading(e))e=e.substring(n.raw.length),t.push(n);else{if(r=e,this.options.extensions&&this.options.extensions.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(n=this.tokenizer.paragraph(r)))s=t[t.length-1],i&&"paragraph"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n),i=r.length!==e.length,e=e.substring(n.raw.length);else if(n=this.tokenizer.text(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n,s,r,i,l,o,a=e;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(i=this.tokenizer.rules.inline.reflinkSearch.exec(a));)e.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(i=this.tokenizer.rules.inline.blockSkip.exec(a));)a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(i=this.tokenizer.rules.inline.anyPunctuation.exec(a));)a=a.slice(0,i.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;e;)if(l||(o=""),l=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.escape(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.tag(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.link(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.emStrong(e,a,o))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.codespan(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.br(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.del(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.autolink(e))e=e.substring(n.raw.length),t.push(n);else if(this.state.inLink||!(n=this.tokenizer.url(e))){if(r=e,this.options.extensions&&this.options.extensions.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(n=this.tokenizer.inlineText(r))e=e.substring(n.raw.length),"_"!==n.raw.slice(-1)&&(o=n.raw.slice(-1)),l=!0,s=t[t.length-1],s&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}else e=e.substring(n.raw.length),t.push(n);return t}}class se{options;constructor(t){this.options=t||e.defaults}code(e,t,n){const s=(t||"").match(/^\S*/)?.[0];return e=e.replace(/\n$/,"")+"\n",s?'
'+(n?e:c(e,!0))+"
\n":"
"+(n?e:c(e,!0))+"
\n"}blockquote(e){return`
\n${e}
\n`}html(e,t){return e}heading(e,t,n){return`${e}\n`}hr(){return"
\n"}list(e,t,n){const s=t?"ol":"ul";return"<"+s+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+"\n"}listitem(e,t,n){return`
  • ${e}
  • \n`}checkbox(e){return"'}paragraph(e){return`

    ${e}

    \n`}table(e,t){return t&&(t=`${t}`),"\n\n"+e+"\n"+t+"
    \n"}tablerow(e){return`\n${e}\n`}tablecell(e,t){const n=t.header?"th":"td";return(t.align?`<${n} align="${t.align}">`:`<${n}>`)+e+`\n`}strong(e){return`${e}`}em(e){return`${e}`}codespan(e){return`${e}`}br(){return"
    "}del(e){return`${e}`}link(e,t,n){const s=g(e);if(null===s)return n;let r='
    ",r}image(e,t,n){const s=g(e);if(null===s)return n;let r=`${n}0&&"paragraph"===n.tokens[0].type?(n.tokens[0].text=e+" "+n.tokens[0].text,n.tokens[0].tokens&&n.tokens[0].tokens.length>0&&"text"===n.tokens[0].tokens[0].type&&(n.tokens[0].tokens[0].text=e+" "+n.tokens[0].tokens[0].text)):n.tokens.unshift({type:"text",text:e+" "}):o+=e+" "}o+=this.parse(n.tokens,i),l+=this.renderer.listitem(o,r,!!s)}n+=this.renderer.list(l,t,s);continue}case"html":{const e=r;n+=this.renderer.html(e.text,e.block);continue}case"paragraph":{const e=r;n+=this.renderer.paragraph(this.parseInline(e.tokens));continue}case"text":{let i=r,l=i.tokens?this.parseInline(i.tokens):i.text;for(;s+1{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new se(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new w(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new le;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.hooks[s],i=t[s];le.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return ne.lex(e,t??this.defaults)}parser(e,t){return ie.parse(e,t??this.defaults)}#e(e,t){return(n,s)=>{const r={...s},i={...this.defaults,...r};!0===this.defaults.async&&!1===r.async&&(i.silent||console.warn("marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored."),i.async=!0);const l=this.#t(!!i.silent,!!i.async);if(null==n)return l(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof n)return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i),i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(n):n).then((t=>e(t,i))).then((e=>i.hooks?i.hooks.processAllTokens(e):e)).then((e=>i.walkTokens?Promise.all(this.walkTokens(e,i.walkTokens)).then((()=>e)):e)).then((e=>t(e,i))).then((e=>i.hooks?i.hooks.postprocess(e):e)).catch(l);try{i.hooks&&(n=i.hooks.preprocess(n));let s=e(n,i);i.hooks&&(s=i.hooks.processAllTokens(s)),i.walkTokens&&this.walkTokens(s,i.walkTokens);let r=t(s,i);return i.hooks&&(r=i.hooks.postprocess(r)),r}catch(e){return l(e)}}}#t(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+c(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const ae=new oe;function ce(e,t){return ae.parse(e,t)}ce.options=ce.setOptions=function(e){return ae.setOptions(e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.getDefaults=t,ce.defaults=e.defaults,ce.use=function(...e){return ae.use(...e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.walkTokens=function(e,t){return ae.walkTokens(e,t)},ce.parseInline=ae.parseInline,ce.Parser=ie,ce.parser=ie.parse,ce.Renderer=se,ce.TextRenderer=re,ce.Lexer=ne,ce.lexer=ne.lex,ce.Tokenizer=w,ce.Hooks=le,ce.parse=ce;const he=ce.options,pe=ce.setOptions,ue=ce.use,ke=ce.walkTokens,ge=ce.parseInline,fe=ce,de=ie.parse,xe=ne.lex;e.Hooks=le,e.Lexer=ne,e.Marked=oe,e.Parser=ie,e.Renderer=se,e.TextRenderer=re,e.Tokenizer=w,e.getDefaults=t,e.lexer=xe,e.marked=ce,e.options=he,e.parse=fe,e.parseInline=ge,e.parser=de,e.setOptions=pe,e.use=ue,e.walkTokens=ke})); diff --git a/packages/webui/public/styles/main.css b/packages/webui/public/styles/main.css deleted file mode 100644 index 922ae5cd..00000000 --- a/packages/webui/public/styles/main.css +++ /dev/null @@ -1,3852 +0,0 @@ -/* ============================================================ - 主题变量 — v3 "Ink & Paper"(墨与纸) - 纯黑白灰单色配色:中性表面 + 白/黑 accent + 器物感细节。 - 语义色降饱和(success/warning 灰阶,danger 保留哑红用于报错)。 - --on-accent: accent 背景上的反色文字(深色主题=墨黑,浅色主题=纸白)。 - ============================================================ */ -/* v0.5.bh: 首次加载无 data-theme 时跟随系统 — 避免 light 闪一下 */ -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { - --bg: #0b0b0c; - --bg-elevated: #141416; - --bg-sidebar: #101012; - --bg-hover: #1c1c1f; - --bg-active: #26262a; - --bg-input: #131315; - --text: #ececee; - --text-secondary: #a2a2a8; - --text-tertiary: #6d6d74; - --border: #26262a; - --border-light: #1d1d20; - --accent: #f4f4f5; - --accent-hover: #ffffff; - --accent-bg: rgba(244, 244, 245, 0.10); - --accent-text: #e4e4e7; - --on-accent: #101012; - --success: #9d9da3; - --warning: #c8c8cd; - --danger: #cc6b5c; - --status-on: #3fbf7f; /* v3: 功能性"开启"状态绿 (LAN 图标等), 单色主题下唯一保留的语义绿 */ - --shadow-sm: 0 1px 2px rgba(0,0,0,0.35); - --shadow-md: 0 4px 10px rgba(0,0,0,0.42); - --shadow-lg: 0 12px 32px rgba(0,0,0,0.55); - --user-accent: #f4f4f5; - /* v2 新增 */ - --accent-glow: rgba(244, 244, 245, 0.12); - --hairline: rgba(236, 236, 238, 0.08); - --font-mono: ui-monospace, "Cascadia Code", Consolas, "SFMono-Regular", Menlo, monospace; - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; - } -} -:root[data-theme="light"] { - --bg: #fafafa; - --bg-elevated: #ffffff; - --bg-sidebar: #f4f4f5; - --bg-hover: #ededee; - --bg-active: #e2e2e4; - --bg-input: #ffffff; - --text: #1a1a1c; - --text-secondary: #5f5f66; - --text-tertiary: #98989e; - --border: #e2e2e4; - --border-light: #ededee; - --accent: #17171a; - --accent-hover: #000000; - --accent-bg: #ededee; - --accent-text: #2a2a2e; - --on-accent: #ffffff; - --success: #75757c; - --warning: #4f4f56; - --danger: #bf5645; - --status-on: #1e9e5a; /* v3: 功能性"开启"状态绿 */ - --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); - --shadow: 0 4px 14px rgba(0,0,0,0.09); - --shadow-lg: 0 14px 38px rgba(0,0,0,0.14); - --user-accent: #17171a; - /* v2 新增 */ - --accent-glow: rgba(23, 23, 26, 0.10); - --hairline: rgba(26, 26, 28, 0.08); - --font-mono: ui-monospace, "Cascadia Code", Consolas, "SFMono-Regular", Menlo, monospace; - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; -} -:root[data-theme="dark"] { - --bg: #0b0b0c; - --bg-elevated: #141416; - --bg-sidebar: #101012; - --bg-hover: #1c1c1f; - --bg-active: #26262a; - --bg-input: #131315; - --text: #ececee; - --text-secondary: #a2a2a8; - --text-tertiary: #6d6d74; - --border: #26262a; - --border-light: #1d1d20; - --accent: #f4f4f5; - --accent-hover: #ffffff; - --accent-bg: rgba(244, 244, 245, 0.10); - --accent-text: #e4e4e7; - --on-accent: #101012; - --success: #9d9da3; - --warning: #c8c8cd; - --danger: #cc6b5c; - --status-on: #3fbf7f; /* v3: 功能性"开启"状态绿 (LAN 图标等), 单色主题下唯一保留的语义绿 */ - --shadow-sm: 0 1px 2px rgba(0,0,0,0.35); - --shadow: 0 4px 14px rgba(0,0,0,0.45); - --shadow-lg: 0 14px 38px rgba(0,0,0,0.55); - --user-accent: #f4f4f5; - /* v2 新增 */ - --accent-glow: rgba(244, 244, 245, 0.12); - --hairline: rgba(236, 236, 238, 0.08); - --font-mono: ui-monospace, "Cascadia Code", Consolas, "SFMono-Regular", Menlo, monospace; - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; -} - -/* ============================================================ - Reset - ============================================================ */ -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } -html, body { height: 100%; overflow: hidden; } -body { - font-family: "Segoe UI Variable Text", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; - font-size: 14px; - line-height: 1.55; - color: var(--text); - background: - radial-gradient(1200px 500px at 70% -10%, var(--accent-glow), transparent 60%), - var(--bg); - background-attachment: fixed; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - transition: background 0.25s, color 0.2s; -} -button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; padding: 0; text-align: left; } -button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-sm); } -input, textarea { font: inherit; color: inherit; } -input:focus, textarea:focus { outline: none; } -a { color: var(--accent); text-decoration: none; } -::selection { background: var(--accent-bg); color: var(--accent-text); } -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); } -[hidden] { display: none !important; } - -/* ============================================================ - App layout - ============================================================ */ -.app { - display: grid; - grid-template-rows: auto 1fr; - height: 100vh; - position: relative; -} -.topbar { - height: 44px; - border-bottom: 1px solid var(--border); - background: var(--bg-elevated); - display: flex; - align-items: center; - padding: 0 16px; - gap: 12px; - z-index: 10; -} -.topbar-brand { - display: flex; - align-items: center; - gap: 8px; - font-weight: 600; - font-size: 15px; -} -.topbar-logo { - width: 24px; height: 24px; - display: block; - flex-shrink: 0; - cursor: pointer; /* v0.5.bx-15: 双击可重置 ask_user 弹窗 */ - border-radius: 6px; - transition: opacity 0.15s, transform 0.15s; -} -.topbar-logo:hover { opacity: 0.85; } -.topbar-logo:active { transform: scale(0.95); } - border-radius: 6px; - overflow: hidden; -} -.topbar-version { - font-size: 11px; - color: var(--text-tertiary); - background: var(--bg-hover); - padding: 2px 6px; - border-radius: 4px; - margin-left: -2px; -} -/* v0.5.bx-37: BETA 标识 — v3 单色化: 跟随 accent (premium.css 有皮肤层覆盖) */ -.topbar-beta { - font-size: 10px; - font-weight: 700; - color: var(--on-accent); - background: var(--accent); - padding: 2px 6px; - border-radius: 4px; - letter-spacing: 0.5px; - margin-left: 2px; - user-select: none; -} -.topbar-status { - margin-left: auto; - display: flex; - gap: 8px; - font-size: 12px; - color: var(--text-secondary); -} -.chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - background: var(--bg-hover); - border-radius: 999px; - font-size: 12px; - color: var(--text-secondary); -} -/* v0.5.bp: 局域网访问链接 chip — 用
    渲染所以继承 cursor:pointer + 去掉下划线;hover 加背景色表示可点 */ -a.chip-lan-link { - text-decoration: none; - cursor: pointer; - color: var(--accent); - border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); - transition: background-color 0.15s, border-color 0.15s; -} -a.chip-lan-link:hover { - background: color-mix(in srgb, var(--accent) 12%, var(--bg-hover)); - border-color: color-mix(in srgb, var(--accent) 60%, transparent); -} -/* v0.5.bx-37: 强制刷新按钮 — 手机/平板浏览器 hard refresh 麻烦, 一键绕过 HTTP cache */ -button.chip-force-reload { - cursor: pointer; - color: var(--text-secondary); - border: 1px solid var(--border); - background: var(--bg-sidebar); - font: inherit; - display: inline-flex; - align-items: center; - gap: 4px; - padding: 3px 8px; - border-radius: 12px; - font-size: 11px; - transition: all 0.15s; -} -button.chip-force-reload:hover { - background: var(--bg-hover); - border-color: var(--accent); - color: var(--accent); -} -button.chip-force-reload:disabled { - opacity: 0.5; - cursor: wait; -} -button.chip-force-reload.loading svg { - animation: force-reload-spin 0.8s linear infinite; -} -button.chip-force-reload .icon { - width: 12px; - height: 12px; - flex-shrink: 0; -} -@keyframes force-reload-spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} -a.chip-lan-link .icon { - width: 12px; - height: 12px; - flex-shrink: 0; -} -.chip-dot { - width: 6px; height: 6px; border-radius: 50%; - background: var(--text-tertiary); -} -.chip[data-status="running"] .chip-dot { background: var(--accent); animation: pulse 1.5s infinite; } -.chip[data-status="loading"] .chip-dot { background: var(--warning); animation: pulse 1s infinite; } -.chip[data-status="completed"] .chip-dot { background: var(--success); } -.chip[data-status="error"] .chip-dot { background: var(--danger); } -.chip[data-status="offline"] .chip-dot { background: var(--text-tertiary); } -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} - -/* v0.5.al: chip-workspace — 顶栏可点击切换工作区(用