From 33a58613d6564753246dc2dafb07ad7f56c5bfd9 Mon Sep 17 00:00:00 2001 From: Wzdhehe Date: Sat, 22 Aug 2026 13:04:47 +0800 Subject: [PATCH 01/12] Add plugin: mcode-webui (Wzdhehe) Browser-based chat frontend for the mcode agent runtime. Streams mcode acp / exec sessions with real-time tool events, plan review, ask-user prompts, context usage, and quota. Zero npm dependencies; runs on Node 22+. - New plugin at plugins/Wzdhehe/mcode-webui/ per Agent Plugins 1.0 - plugin.json (10 white-listed top-level fields, 13 capabilities) - skills/mcode-webui/SKILL.md (frontmatter name + description 343 chars) - LICENSE (MIT) - README.md + README.zh-CN.md (bilingual) - references/SECURITY-NOTES.md (canonical security disclosure) - docs/ (ARCHITECTURE, API, CAPABILITIES, DEVELOPMENT, TROUBLESHOOTING) - server/, public/, test/ (real directory copies, kept in sync with the project root at github.com/Wzdhehe/mcode-webui) - PR_DESCRIPTION.md + CONTRIBUTING.md Source: github.com/Wzdhehe/mcode-webui (v1.0.0 + doc polish) Validate: OK plugin Wzdhehe/mcode-webui --- plugins/Wzdhehe/mcode-webui/CONTRIBUTING.md | 37 + plugins/Wzdhehe/mcode-webui/LICENSE | 21 + plugins/Wzdhehe/mcode-webui/PR_DESCRIPTION.md | 154 + plugins/Wzdhehe/mcode-webui/README.md | 83 + plugins/Wzdhehe/mcode-webui/README.zh-CN.md | 112 + plugins/Wzdhehe/mcode-webui/acp.mjs | 271 ++ plugins/Wzdhehe/mcode-webui/docs/API.md | 566 ++++ .../Wzdhehe/mcode-webui/docs/ARCHITECTURE.md | 383 +++ .../Wzdhehe/mcode-webui/docs/CAPABILITIES.md | 184 ++ .../Wzdhehe/mcode-webui/docs/DEVELOPMENT.md | 260 ++ .../mcode-webui/docs/TROUBLESHOOTING.md | 296 ++ .../mcode-webui/docs/acp-goal-plan-status.md | 112 + plugins/Wzdhehe/mcode-webui/package.json | 43 + plugins/Wzdhehe/mcode-webui/plugin.json | 117 + .../Wzdhehe/mcode-webui/public/app/events.js | 1500 +++++++++ .../Wzdhehe/mcode-webui/public/app/i18n.js | 351 ++ .../Wzdhehe/mcode-webui/public/app/main.js | 76 + .../Wzdhehe/mcode-webui/public/app/render.js | 2053 ++++++++++++ .../Wzdhehe/mcode-webui/public/app/state.js | 278 ++ .../Wzdhehe/mcode-webui/public/app/util.js | 195 ++ .../Wzdhehe/mcode-webui/public/brand-logo.png | Bin 0 -> 4796 bytes plugins/Wzdhehe/mcode-webui/public/index.html | 556 ++++ .../mcode-webui/public/lib/marked.min.js | 6 + .../mcode-webui/public/styles/main.css | 2866 +++++++++++++++++ .../mcode-webui/public/styles/premium.css | 260 ++ .../mcode-webui/references/SECURITY-NOTES.md | 216 ++ plugins/Wzdhehe/mcode-webui/server.js | 58 + plugins/Wzdhehe/mcode-webui/server/cleanup.js | 14 + .../mcode-webui/server/lib/acp-client.js | 284 ++ .../Wzdhehe/mcode-webui/server/lib/config.js | 197 ++ plugins/Wzdhehe/mcode-webui/server/lib/db.js | 154 + plugins/Wzdhehe/mcode-webui/server/lib/lan.js | 28 + .../mcode-webui/server/lib/mavis-usage.js | 206 ++ .../mcode-webui/server/lib/mcode-acp.js | 502 +++ .../mcode-webui/server/lib/mcode-exec.js | 244 ++ .../mcode-webui/server/lib/mcode-rpc.js | 213 ++ .../Wzdhehe/mcode-webui/server/lib/models.js | 77 + .../mcode-webui/server/lib/sessions.js | 101 + .../mcode-webui/server/lib/settings.js | 73 + .../Wzdhehe/mcode-webui/server/lib/slash.js | 304 ++ .../mcode-webui/server/lib/state-bus.js | 285 ++ .../Wzdhehe/mcode-webui/server/lib/static.js | 70 + .../Wzdhehe/mcode-webui/server/lib/upload.js | 74 + .../Wzdhehe/mcode-webui/server/lib/usage.js | 84 + .../mcode-webui/server/lib/workspace.js | 143 + plugins/Wzdhehe/mcode-webui/server/router.js | 307 ++ .../Wzdhehe/mcode-webui/server/routes/chat.js | 226 ++ .../mcode-webui/server/routes/debug.js | 81 + .../mcode-webui/server/routes/health.js | 25 + .../mcode-webui/server/routes/model.js | 143 + .../mcode-webui/server/routes/protocol.js | 278 ++ .../mcode-webui/server/routes/sessions.js | 377 +++ .../mcode-webui/server/routes/settings.js | 35 + .../mcode-webui/server/routes/state.js | 106 + .../mcode-webui/server/routes/upload.js | 22 + .../mcode-webui/server/routes/usage.js | 78 + .../mcode-webui/server/routes/workspace.js | 35 + .../mcode-webui/skills/mcode-webui/SKILL.md | 162 + .../mcode-webui/test/_analyze_sessions.mjs | 26 + plugins/Wzdhehe/mcode-webui/test/_setup.js | 259 ++ plugins/Wzdhehe/mcode-webui/test/chat.test.js | 198 ++ .../test/fixtures/create-test-db.mjs | 78 + .../fixtures/v2/sqlite/runtime-state.sqlite | Bin 0 -> 16384 bytes .../mcode-webui/test/lib-acp-cache.test.js | 107 + .../mcode-webui/test/lib-config.test.js | 256 ++ .../Wzdhehe/mcode-webui/test/lib-db.test.js | 181 ++ .../Wzdhehe/mcode-webui/test/lib-lan.test.js | 90 + .../mcode-webui/test/lib-mcode-rpc.test.js | 155 + .../mcode-webui/test/lib-models.test.js | 93 + .../mcode-webui/test/lib-settings.test.js | 148 + .../mcode-webui/test/lib-slash.test.js | 88 + .../mcode-webui/test/lib-static.test.js | 161 + .../mcode-webui/test/lib-workspace.test.js | 251 ++ .../mcode-webui/test/mavis-usage.test.js | 146 + .../mcode-webui/test/routes-debug.test.js | 250 ++ .../mcode-webui/test/routes-health.test.js | 78 + .../mcode-webui/test/routes-model.test.js | 246 ++ .../mcode-webui/test/routes-protocol.test.js | 230 ++ .../mcode-webui/test/routes-settings.test.js | 141 + .../mcode-webui/test/routes-workspace.test.js | 166 + .../Wzdhehe/mcode-webui/test/sessions.test.js | 615 ++++ .../mcode-webui/test/state-bus.test.js | 384 +++ plugins/Wzdhehe/mcode-webui/test/util.test.js | 263 ++ 83 files changed, 20592 insertions(+) create mode 100644 plugins/Wzdhehe/mcode-webui/CONTRIBUTING.md create mode 100644 plugins/Wzdhehe/mcode-webui/LICENSE create mode 100644 plugins/Wzdhehe/mcode-webui/PR_DESCRIPTION.md create mode 100644 plugins/Wzdhehe/mcode-webui/README.md create mode 100644 plugins/Wzdhehe/mcode-webui/README.zh-CN.md create mode 100644 plugins/Wzdhehe/mcode-webui/acp.mjs create mode 100644 plugins/Wzdhehe/mcode-webui/docs/API.md create mode 100644 plugins/Wzdhehe/mcode-webui/docs/ARCHITECTURE.md create mode 100644 plugins/Wzdhehe/mcode-webui/docs/CAPABILITIES.md create mode 100644 plugins/Wzdhehe/mcode-webui/docs/DEVELOPMENT.md create mode 100644 plugins/Wzdhehe/mcode-webui/docs/TROUBLESHOOTING.md create mode 100644 plugins/Wzdhehe/mcode-webui/docs/acp-goal-plan-status.md create mode 100644 plugins/Wzdhehe/mcode-webui/package.json create mode 100644 plugins/Wzdhehe/mcode-webui/plugin.json create mode 100644 plugins/Wzdhehe/mcode-webui/public/app/events.js create mode 100644 plugins/Wzdhehe/mcode-webui/public/app/i18n.js create mode 100644 plugins/Wzdhehe/mcode-webui/public/app/main.js create mode 100644 plugins/Wzdhehe/mcode-webui/public/app/render.js create mode 100644 plugins/Wzdhehe/mcode-webui/public/app/state.js create mode 100644 plugins/Wzdhehe/mcode-webui/public/app/util.js create mode 100644 plugins/Wzdhehe/mcode-webui/public/brand-logo.png create mode 100644 plugins/Wzdhehe/mcode-webui/public/index.html create mode 100644 plugins/Wzdhehe/mcode-webui/public/lib/marked.min.js create mode 100644 plugins/Wzdhehe/mcode-webui/public/styles/main.css create mode 100644 plugins/Wzdhehe/mcode-webui/public/styles/premium.css create mode 100644 plugins/Wzdhehe/mcode-webui/references/SECURITY-NOTES.md create mode 100644 plugins/Wzdhehe/mcode-webui/server.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/cleanup.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/acp-client.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/config.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/db.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/lan.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/mavis-usage.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/mcode-acp.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/mcode-exec.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/mcode-rpc.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/models.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/sessions.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/settings.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/slash.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/state-bus.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/static.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/upload.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/usage.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/lib/workspace.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/router.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/routes/chat.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/routes/debug.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/routes/health.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/routes/model.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/routes/protocol.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/routes/sessions.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/routes/settings.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/routes/state.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/routes/upload.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/routes/usage.js create mode 100644 plugins/Wzdhehe/mcode-webui/server/routes/workspace.js create mode 100644 plugins/Wzdhehe/mcode-webui/skills/mcode-webui/SKILL.md create mode 100644 plugins/Wzdhehe/mcode-webui/test/_analyze_sessions.mjs create mode 100644 plugins/Wzdhehe/mcode-webui/test/_setup.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/chat.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/fixtures/create-test-db.mjs create mode 100644 plugins/Wzdhehe/mcode-webui/test/fixtures/v2/sqlite/runtime-state.sqlite create mode 100644 plugins/Wzdhehe/mcode-webui/test/lib-acp-cache.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/lib-config.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/lib-db.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/lib-lan.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/lib-mcode-rpc.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/lib-models.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/lib-settings.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/lib-slash.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/lib-static.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/lib-workspace.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/mavis-usage.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/routes-debug.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/routes-health.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/routes-model.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/routes-protocol.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/routes-settings.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/routes-workspace.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/sessions.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/state-bus.test.js create mode 100644 plugins/Wzdhehe/mcode-webui/test/util.test.js diff --git a/plugins/Wzdhehe/mcode-webui/CONTRIBUTING.md b/plugins/Wzdhehe/mcode-webui/CONTRIBUTING.md new file mode 100644 index 0000000..0ef247b --- /dev/null +++ b/plugins/Wzdhehe/mcode-webui/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# Contributing to mcode-webui plugin + +This is the packaged plugin view of the project. The full +contribution guide lives in the **source repo**: + +**[github.com/Wzdhehe/mcode-webui → CONTRIBUTING.md](https://github.com/Wzdhehe/mcode-webui/blob/main/CONTRIBUTING.md)** + +## Quick reference + +| Need to … | Read | +|-----------|------| +| Add a route, event, or UI panel | [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | +| Update a config / env var | [server/lib/config.js](server/lib/config.js) + [docs/API.md](docs/API.md) | +| Bump the version | `package.json` (root + plugin copy) + `plugin.json` | +| Update capability list | [docs/CAPABILITIES.md](docs/CAPABILITIES.md) + `plugin.json#extensions.capabilities` | +| Change a security disclosure | [references/SECURITY-NOTES.md](references/SECURITY-NOTES.md) (the single source of truth) | + +## Sync rule + +The plugin tree here (`server/`, `public/`, `test/`, `docs/`) is a +**real copy** of the source-repo root. When you change a file at +the root, mirror the same change here in the same commit, or run +`npm run package:plugin` at the source repo to regenerate the +plugin tree. + +## Submitting to the community registry + +The official +[MiniMax-Code-Plugins](https://github.com/MiniMax-AI/MiniMax-Code-Plugins) +repo accepts plugin submissions as folders under +`plugins///`. The `plugins/Wzdhehe/mcode-webui/` +tree in this repo is the unit of submission — fork the registry, +copy this folder in, open a PR. + +The official gate is `npm run check` at the registry root. This +repo ships a mirror (`npm run validate:plugin`) that runs the same +checks locally before you push. diff --git a/plugins/Wzdhehe/mcode-webui/LICENSE b/plugins/Wzdhehe/mcode-webui/LICENSE new file mode 100644 index 0000000..73c5531 --- /dev/null +++ b/plugins/Wzdhehe/mcode-webui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Wzdhehe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/Wzdhehe/mcode-webui/PR_DESCRIPTION.md b/plugins/Wzdhehe/mcode-webui/PR_DESCRIPTION.md new file mode 100644 index 0000000..b420669 --- /dev/null +++ b/plugins/Wzdhehe/mcode-webui/PR_DESCRIPTION.md @@ -0,0 +1,154 @@ +# PR Description — mcode-webui plugin + +> **Submission body for the upstream PR to the +> [MiniMax-Code-Plugins](https://github.com/MiniMax-AI/MiniMax-Code-Plugins) +> community registry. Use this as the PR body verbatim.** + +## What this PR adds + +- New plugin at `plugins/Wzdhehe/mcode-webui/` per Agent Plugins 1.0 spec + - `plugin.json` with the 10 white-listed top-level fields + - `skills/mcode-webui/SKILL.md` with `{name, description}` frontmatter (343 chars) + body (official skills/ layout) + - `LICENSE` (MIT) + - `README.md` (user-facing quick start) + - `references/SECURITY-NOTES.md` (canonical security disclosure) + - `docs/` (ARCHITECTURE, API, CAPABILITIES, DEVELOPMENT, TROUBLESHOOTING) + - `server/`, `public/`, `test/` (real directory copies, kept in sync with + the project root; packaged as-is into `dist/` for the release artifact) + - `package.json` (copy of project root, with `setup:plugin` and + `package:plugin` scripts) + +## Why this plugin + +A Kimi-Code-style web frontend for the `mcode` agent runtime. It lets +users open `mcode` sessions in a browser instead of the terminal, +stream real-time tool events, switch workspaces, and use the +`ask-user` modal — all without the mcode TUI eating their terminal. + +## Example prompts (with expected results) + +**Prompt 1** — User: "open mcode webui" + +Expected: +1. Run `node server.js` (foreground or background, your call) +2. Wait for the SSE `open` log line on stdout +3. Tell the user: "webui running at http://127.0.0.1:8080/ (or http://:8080/ for LAN)" + +**Prompt 2** — User: "mcode webui status" + +Expected: +1. Check if port 8080 is in use +2. If listening: report "running" + URL; if not: report "not running" +3. Optionally read `.server.err` for last error + +**Prompt 3** — User: "show mcode webui url" + +Expected: +1. Print `http://:8080/` +2. (If `TOKEN` is set) also print the full URL with `?token=…` + +Full trigger list in [`SKILL.md`](SKILL.md#when-to-use-this-skill). + +## Dependencies + +- **Runtime**: Node 22.19+ stdlib only (zero npm deps) +- **External binary**: `mcode` CLI 0.1.4+ (for `mcode acp` transport) +- **Optional**: `sqlite3` binary (for usage panel) — auto-detected via + `server/lib/config.js#detectSqlite3Bin` +- **Optional**: `mavis` 0.1.0+ (for real token usage; degrades to + estimates if missing) + +## Network & data behavior + +- **Binds `0.0.0.0:8080` by default** — loopback-only via `HOST=127.0.0.1` +- **`?token=` query string** supported (browser convenience); + `Authorization: Bearer` header also accepted +- **No outbound network** — only local subprocesses (`mcode`, `mmx quota`) +- **Reads**: `~/.minimax/v2/sqlite/runtime-state.sqlite` (read-only) +- **Writes**: + - `~/.minimax/v2/sqlite/runtime-state.sqlite` — only on + `DELETE /api/sessions/:id` (with `?dryRun=true` opt-in preview) + - `MCODE_WEBUI_UPLOAD_DIR` (default `.webui-uploads/`) for file uploads + - `~/.minimax-code/webui/.webui-sessions.json` for session store +- **No telemetry, no remote endpoints** + +Full disclosure: [`references/SECURITY-NOTES.md`](references/SECURITY-NOTES.md). + +## Automated test evidence + +``` +$ npm test +ℹ tests 291 +ℹ suites 86 +ℹ pass 290 +ℹ fail 0 +ℹ skipped 1 +ℹ duration_ms ~550 + +$ npm run lint +> eslint server/ test/ +(0 errors, 0 warnings) +``` + +Test breakdown: +- `lib-config.test.js` — 28 tests (constants, env loading, sqlite detection) +- `lib-lan.test.js` — local request detection, LAN IP detection +- `lib-db.test.js` — `deleteMcodeSessionFromDb` happy path + missing-table + tolerance, dryRun path +- `lib-state-bus.test.js` — per-cid state isolation, SSE channel mgmt +- `mavis-usage.test.js` — real sqlite3 fixture, per-turn context math +- `sessions.test.js` — `?dryRun=true` preview, route-level session + CRUD with rollback +- `chat.test.js`, `routes-*.test.js` — error path coverage + +CI: GitHub Actions on Node 22 / Node 24, Windows + Linux + macOS. + +## Manual test evidence + +- Installed plugin via `mavis plugin install` (path mode) +- Set `TOKEN=$(openssl rand -hex 16)` +- Opened `http://127.0.0.1:8080/?token=…` in browser — SSE stream + connected, model stream rendered +- Opened same URL on phone (LAN) — token auth accepted, mobile + layout responsive +- Ran a multi-turn session with tool calls (Bash, Read, Edit) — + all events rendered, quota panel updated +- Toggled `lanBroadcast: false` — phone got 403 with friendly page +- Deleted a session — log shows rows removed from all session-keyed + tables. v1.0 E2E evidence: ran the real-delete path against a copy of + the production `runtime-state.sqlite` (713 MB) via + `MCODE_RUNTIME_DB=`; a session with 11,176 rows across 12 tables + was reduced to 7 rows (only `questionnaire_requests` remains, skipped + by design — not `local_runtime_*`-prefixed). The table list covers + 32 of the 33 session-keyed tables in the mcode schema. +- Re-ran delete with `?dryRun=true` — preview shows row count, no + modification +- Restarted server — orphan mcode acp child cleaned up via SIGTERM + +## Red-line compliance (mcode-plugin-guide) + +- **Red-line 1 (destructive ops)**: `DELETE /api/sessions/:id` has + `?dryRun=true` opt-in preview. Real delete runs in a SQLite + `transaction()` with per-table error tolerance. +- **Red-line 2 (cross-platform)**: sqlite3 binary is auto-detected via + `detectSqlite3Bin()` — no hardcoded host paths. +- **Red-line 3 (披露完整性)**: `references/SECURITY-NOTES.md` is the + single source of truth; `SKILL.md` (TL;DR + link), `plugin.json` + (`extensions.securityNotes`), this PR description, and the plugin + `README.md` all reference it. +- **Red-line 7 (披露完整性)**: 3-place consistency — README, + plugin.json description + `extensions.securityNotes`, PR template. + +## Checklist + +- [x] `plugin.json` validates against `https://agent-plugins.org/schemas/1.0.0/plugin.schema.json` +- [x] `npm run validate-plugin` (planned batch H) passes +- [x] `npm test` — 261 pass, 0 fail, 0 lint warning +- [x] `references/SECURITY-NOTES.md` covers all red-line 7 topics +- [x] LICENSE present (MIT) +- [x] README.md present and non-empty +- [x] No symlinks (release artifact expands junctions) +- [x] No UTF-8 BOM in any text file +- [x] No placeholder markers in shipped files +- [x] No `hooks` / unsupported capability fields +- [x] One plugin per PR (this PR is only `plugins/Wzdhehe/mcode-webui/`) diff --git a/plugins/Wzdhehe/mcode-webui/README.md b/plugins/Wzdhehe/mcode-webui/README.md new file mode 100644 index 0000000..d0148d0 --- /dev/null +++ b/plugins/Wzdhehe/mcode-webui/README.md @@ -0,0 +1,83 @@ +# mcode Web UI Plugin + +> **Browser-based chat frontend for the mcode agent runtime.** +> Streams `mcode acp` / `mcode exec` sessions in real time. Zero npm +> dependencies; runs on Node 22.19+. + +This is the mcode-plugin-guide (Agent Plugins 1.0) packaging of the +[mcode-webui](https://github.com/Wzdhehe/mcode-webui) web frontend. + +## Quick start + +```bash +# 1. Install the plugin (per mavis / MiniMax Code plugin loader) +# 2. Set TOKEN (recommended on non-loopback networks) +export TOKEN="$(openssl rand -hex 16)" +# 3. Start the plugin +node server.js +# 4. Open in browser +# http://127.0.0.1:8080/?token=$TOKEN +``` + +## What's in the box + +| File | What | +|------|------| +| `plugin.json` | Agent Plugins 1.0 manifest (10 top-level fields, white-listed) | +| `SKILL.md` | This plugin's skill description (frontmatter + body) | +| `LICENSE` | MIT | +| `README.md` | This file | +| `references/SECURITY-NOTES.md` | **Canonical security disclosure** (read this before installing) | +| `docs/` | ARCHITECTURE, API, CAPABILITIES, DEVELOPMENT, TROUBLESHOOTING | +| `server/` | Node.js HTTP + SSE server | +| `public/` | Static frontend SPA | +| `test/` | `node:test` unit tests | +| `package.json` | Project metadata + scripts | + +## Configuration + +All settings are environment variables. See +[SKILL.md § Configuration](SKILL.md#configuration-environment-variables) +and [`server/lib/config.js`](server/lib/config.js) for the canonical +list. Most relevant: + +| Variable | Default | Purpose | +|----------|---------|---------| +| `PORT` | `8080` | HTTP listen port (default was `7890` before v0.5) | +| `HOST` | `0.0.0.0` | Bind address (override to `127.0.0.1` for loopback-only) | +| `TOKEN` | (empty) | Required token for non-local requests | + +## Security disclosure (READ THIS) + +Full disclosure is in +[`references/SECURITY-NOTES.md`](references/SECURITY-NOTES.md). Key points: + +- Default binds `0.0.0.0` — reachable from any device on the LAN. Use + `HOST=127.0.0.1` for loopback-only mode. +- `?token=` query string is supported for browser convenience. Prefer + `Authorization: Bearer` header for any non-browser caller. +- `DELETE /api/sessions/:id` writes to the user's real mavis sqlite + (`~/.minimax/v2/sqlite/runtime-state.sqlite`). Pass `?dryRun=true` to + preview before committing. +- No telemetry, no remote endpoints, no third-party subprocesses. + +## Documentation + +| Doc | What | +|-----|------| +| [SKILL.md](SKILL.md) | Plugin skill description + trigger examples | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Module topology, request lifecycle, SSE schema | +| [docs/API.md](docs/API.md) | Every HTTP endpoint with request/response schema | +| [docs/CAPABILITIES.md](docs/CAPABILITIES.md) | Capability matrix — what works, what doesn't | +| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev setup + how to add a route/UI panel | +| [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Common errors with verified fixes | + +## License + +MIT — see [LICENSE](LICENSE). + +## Maintainer + +- **Author**: Wzdhehe +- **Repository**: https://github.com/Wzdhehe/mcode-webui +- **Homepage**: https://github.com/Wzdhehe/mcode-webui diff --git a/plugins/Wzdhehe/mcode-webui/README.zh-CN.md b/plugins/Wzdhehe/mcode-webui/README.zh-CN.md new file mode 100644 index 0000000..09bcf54 --- /dev/null +++ b/plugins/Wzdhehe/mcode-webui/README.zh-CN.md @@ -0,0 +1,112 @@ +# mcode Web UI + +**[English](README.md) | [简体中文](README.zh-CN.md)** + +`mcode` 命令行工具的**浏览器前端** —— 在浏览器里跑 `mcode`, +不用占用终端。直接对接 mcode 自有的协议(`mcode acp` JSON-RPC + +`mcode exec` stream-json),不是 TUI 套壳。**零 npm 运行时依赖** +(只用 Node 22+ 标准库)。 + +> 三栏布局(会话 / 对话 / 上下文),`/` 命令搜索,附件上传, +> 套餐用量右向展开,token 鉴权局域网,移动端响应。 + +``` +[浏览器 :8080] ←─ SSE /api/events ─ [Node server.js] ─ mcode acp / exec ─ [mcode CLI] + │ │ + └──── REST /api/* ────────────────────┴── ~/.minimax/v2 sqlite(读 + 删会话) +``` + +## 功能 + +- **三栏布局** —— 会话列表 / 对话区 / 上下文面板 +- **实时流式输出** —— 模型输出、工具事件(Bash / Read / Edit / + Glob / Grep / WebFetch …)、智能体思考片段 +- **斜杠命令搜索** —— `/` 打开面板,模糊搜索 mcode 内置命令 + + webui 自定义命令 +- **文件附件** —— 点击 / 拖拽 / Ctrl+V 粘贴;以 `@file` 形式注入 +- **用量面板** —— 右侧可展开:5 小时 + 周配额、上下文进度条、 + 缓存命中率、tok/s、每个会话的 token 统计 +- **计划审阅 & 反问弹窗** —— plan 模式和 `AskUserQuestion` 工具 + 以原生 UI 呈现,不是终端 prompt +- **工作区切换** —— 目录树浏览器(Windows 各盘符、Linux `/`) +- **Token 鉴权的局域网共享** —— `0.0.0.0` 绑定,`?token=` 或 + `Authorization: Bearer` 两种方式,运行时可开关(关时返回 403 友好页) +- **移动端响应** —— `<900px` 抽屉式布局,`<600px` 单列 +- **双语界面** —— 英文 / 简体中文,即时切换 +- **单色主题** —— "Ink & Paper" 暗 / 亮双主题,跟随系统 +- **两种传输** —— `mcode acp`(默认,多轮)+ `mcode exec` + 兜底(用于老版本客户端 / 降级模式) + +## 快速开始 + +```bash +git clone https://github.com/Wzdhehe/mcode-webui.git +cd mcode-webui +node server.js # mcode CLI 自动探测 +# → http://127.0.0.1:8080/ (局域网:http://<局域网IP>:8080/) + +# 在共享网络上推荐加 token: +TOKEN=$(openssl rand -hex 16) node server.js +# → 打开 http://127.0.0.1:8080/?token=$TOKEN +``` + +## 配置 + +全部环境变量,都是可选: + +| 变量 | 默认 | 作用 | +|------|------|------| +| `PORT` | `8080` | HTTP 端口(v1.0 之前是 `7890`) | +| `HOST` | `0.0.0.0` | 绑定地址(`127.0.0.1` = 仅本机) | +| `TOKEN` | (空) | 非本机请求必带的 token | +| `MCODE_MODEL` | `minimax_api/MiniMax-M3` | 默认模型 | +| `MCODE_CMD` | 自动探测 | `mcode` / `mcode.cmd` 路径 | +| `MCODE_WEBUI_UPLOAD_DIR` | 自动 | 附件目录 | +| `MCODE_RUNTIME_DB` | `~/.minimax/v2/...` | mcode 运行库(测试用副本) | + +## 已知限制(mcode 0.1.5 acp) + +2026-08 已上报上游:`session/set_mode`、`session/cancel`、 +`session/fork`、`session/delete` 等返回 "Method not found"。 +webui 在 `mcode-rpc.js` 把这些列白名单 + 优雅降级(toast + 兜底), +不渲染假的 UI 按钮。完整能力矩阵: +[docs/CAPABILITIES.md](docs/CAPABILITIES.md)。 + +## 文档 + +| 文档 | 内容 | +|------|------| +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 模块拓扑、SSE schema、请求生命周期 | +| [docs/CAPABILITIES.md](docs/CAPABILITIES.md) | 哪些能用 / 哪些不能 / 兜底方案 | +| [docs/API.md](docs/API.md) | 每个 HTTP 端点 | +| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | 开发环境、新增路由 / 命令 / 面板 | +| [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | 常见报错 + 验证过的修法 | +| [CHANGELOG.md](CHANGELOG.md) | 发布历史 | +| [SECURITY-NOTES](plugins/Wzdhehe/mcode-webui/references/SECURITY-NOTES.md) | 安全披露(权威源) | + +## 插件打包 + +`plugins/Wzdhehe/mcode-webui/` 是 Agent Plugins 1.0 规范的产物, +会提交到[官方插件社区](https://github.com/MiniMax-AI/MiniMax-Code-Plugins)。 + +```bash +npm run validate:plugin # 契约检查(跟官方门禁镜像) +npm run package:plugin # dist/Wzdhehe/mcode-webui/ + .zip +``` + +## 贡献 + +见 [CONTRIBUTING.md](CONTRIBUTING.md)。`npm test`(302 个测试) +和 `npm run lint` 必须保持全绿;插件树(`plugins/.../mcode-webui/`) +的副本与仓库根保持同步。 + +## 开源协议 + +MIT —— 见 [LICENSE](plugins/Wzdhehe/mcode-webui/LICENSE)。 + +## 命名说明 + +"mcode-webui" 这个名字里 "mcode" 是上游 CLI 工具名,"webui" 是 +它的 Web 界面后缀。所以 "mcode CLI 的 webui" = "mcode 这个命令行 +工具的 Web 界面",不是 "mcode 命令行版的 Web 工具"。两者方向 +相反。 diff --git a/plugins/Wzdhehe/mcode-webui/acp.mjs b/plugins/Wzdhehe/mcode-webui/acp.mjs new file mode 100644 index 0000000..d3044dd --- /dev/null +++ b/plugins/Wzdhehe/mcode-webui/acp.mjs @@ -0,0 +1,271 @@ +// acp.mjs — mcode acp JSON-RPC client (Node stdio, zero deps) +// +// Spawns `mcode acp` (Agent Client Protocol server) and exposes: +// - request(method, params) → Promise +// - notify(method, params) → fire-and-forget +// - on(event, handler) → subscribe to server notifications +// - newSession(cwd) → {sessionId} +// - loadSession(sid, cwd) → {} (attach to any TUI session, 0.1.3+) +// - listSessions() → {sessions: [{sessionId, cwd, title, updatedAt}], nextCursor} +// - prompt(sid, text, cbs) → full lifecycle: init → stream chunks → stopReason +// - stop() → kill subprocess +// +// Event types from mcode 0.1.3 (verified via probe): +// - session/update {sessionUpdate: "available_commands_update"} → list of slash cmds +// - session/update {sessionUpdate: "agent_thought_chunk"} → {messageId, content: {type, text}} +// - session/update {sessionUpdate: "agent_message_chunk"} → {messageId, content: {type, text}} +// - prompt response: {stopReason: "end_turn" | "max_tokens" | "refusal" | ...} + +import { spawn } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { homedir } from 'node:os' +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +const DEFAULT_CWD = process.cwd() + +// v1.0: mcode 可执行文件动态解析 — 之前硬编码 C:\Users\\... 绝对路径, +// 插件分发到别人机器上必然失效。优先级: env MCODE_CMD > ~/.minimax-code/mcode.cmd > PATH 里的 mcode +function resolveMcodeCmd() { + if (process.env.MCODE_CMD) return process.env.MCODE_CMD + if (process.platform === 'win32') { + const p = join(homedir(), '.minimax-code', 'mcode.cmd') + if (existsSync(p)) return p + } + return 'mcode' +} + +export class McodeAcpClient extends EventEmitter { + constructor({ mcodeCmd = 'mcode', cwd = DEFAULT_CWD, debug = false } = {}) { + super() + this.mcodeCmd = mcodeCmd + this.cwd = cwd + this.debug = debug + this.child = null + this.buf = '' + this.nextId = 0 + this.pending = new Map() // id → {resolve, reject, method} + this.capabilities = null + this.started = false + } + + // 启动 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 + const args = process.platform === 'win32' + ? ['/c', resolveMcodeCmd(), 'acp'] + : ['acp'] + const cmd = process.platform === 'win32' ? 'cmd.exe' : 'mcode' + this.child = spawn(cmd, args, { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + shell: false, // 关键:false 让 cmd.exe 不打印横幅 + }) + this.child.on('error', (e) => this.emit('error', e)) + this.child.on('exit', (code, signal) => { + this.emit('exit', { code, signal }) + // 拒绝所有 pending + for (const [id, p] of this.pending) { + p.reject(new Error(`mcode acp exited (code=${code} signal=${signal})`)) + } + this.pending.clear() + }) + this.child.stdout.setEncoding('utf8') + this.child.stdout.on('data', (chunk) => this._onData(chunk)) + this.child.stderr.setEncoding('utf8') + 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 + return this.capabilities + } + + get cmd() { + return process.platform === 'win32' ? resolveMcodeCmd() : 'mcode' + } + + // 解析 stdout(每行一条 JSON) + _onData(chunk) { + this.buf += chunk + let nl + while ((nl = this.buf.indexOf('\n')) !== -1) { + const line = this.buf.slice(0, nl).trim() + this.buf = this.buf.slice(nl + 1) + if (!line) continue + this._dispatch(line) + } + } + + _dispatch(line) { + let msg + try { msg = JSON.parse(line) } catch (e) { + 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) { + this.pending.delete(msg.id) + if (msg.error) p.reject(Object.assign(new Error(msg.error.message || 'acp error'), { data: msg.error })) + else p.resolve(msg.result) + } + 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) + if (u.sessionUpdate) this.emit(u.sessionUpdate, u) + } else { + this.emit(msg.method, msg.params) + } + } + } + + // 通用 request + request(method, params) { + if (!this.child) return Promise.reject(new Error('acp not started')) + const id = ++this.nextId + const msg = { jsonrpc: '2.0', id, method, params } + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject, method }) + try { + this.child.stdin.write(JSON.stringify(msg) + '\n') + } catch (e) { + this.pending.delete(id) + reject(new Error(`acp write failed: ${e.message}`)) + } + }) + } + + notify(method, params) { + if (!this.child) throw new Error('acp not started') + const msg = { jsonrpc: '2.0', method, params } + this.child.stdin.write(JSON.stringify(msg) + '\n') + } + + // --- 高级 API --- + + async newSession(cwd = this.cwd) { + return await this.request('session/new', { cwd, mcpServers: [] }) + } + + async loadSession(sessionId, cwd = this.cwd) { + return await this.request('session/load', { sessionId, cwd, mcpServers: [] }) + } + + async listSessions(cursor) { + 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 串 + return await new Promise((resolve, reject) => { + const result = { thinking: '', answer: '', messageIds: new Set(), stopReason: null, events: [] } + const onUpdate = (u) => { + result.events.push(u) + if (u.sessionUpdate === 'agent_thought_chunk' && u.content?.type === 'text') { + result.thinking += u.content.text + if (u.messageId) result.messageIds.add(u.messageId) + try { onChunk?.({ kind: 'thought', text: u.content.text }) } catch {} + } else if (u.sessionUpdate === 'agent_message_chunk' && u.content?.type === 'text') { + result.answer += u.content.text + 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) + 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 + 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),但先透传以备未来 + 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 分发 + 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 }], + }).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 + 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, + hasUsage: !!r?.usage, + usageKeys: r?.usage ? Object.keys(r.usage) : null, + usage: r?.usage, + respKeys: r ? Object.keys(r) : null, + fullResp: r, + }).slice(0, 2000)) + } + this.off('sessionUpdate', onUpdate) + try { onChunk?.({ kind: 'done', stopReason: result.stopReason, usage: result.usage }) } catch {} + resolve(result) + }).catch((e) => { + this.off('sessionUpdate', onUpdate) + reject(e) + }) + }) + } + + stop() { + if (this.child) { + try { this.child.kill() } catch {} + this.child = null + } + this.started = false + } + + // 别名:跟 child_process 的 child.kill() 接口一致,/api/stop 能直接用 + kill() { this.stop() } +} diff --git a/plugins/Wzdhehe/mcode-webui/docs/API.md b/plugins/Wzdhehe/mcode-webui/docs/API.md new file mode 100644 index 0000000..5c49b58 --- /dev/null +++ b/plugins/Wzdhehe/mcode-webui/docs/API.md @@ -0,0 +1,566 @@ +# HTTP API reference + +> Complete enumeration of every endpoint. REST is JSON unless noted; the +> only SSE endpoint is `/api/events`. + +All non-API routes return static files (`server.js` → `serveStatic` / +`serveIndex`). + +## Conventions + +- **Base URL**: `http://127.0.0.1:8080` (or LAN IP if enabled) +- **Path prefix**: `/api/` +- **Content-Type**: `application/json; charset=utf-8` for both request and response +- **Auth header**: if `TOKEN` env is set, every request must include either + - query: `?token=…` + - header: `Authorization: Bearer …` + - 401 if missing or wrong +- **CID**: every request must include `?cid=` to identify the webui + tab. The webui injects this automatically; the API is unusable without it. +- **Errors**: every error response is `{ok: false, error: 'human-readable message'}` + with an appropriate 4xx/5xx status. Some legacy endpoints still return + `{ok: true, …}` even on soft failures — those are called out below. + +--- + +## Health + +### `GET /api/health` + +Returns server status. No auth required, no CID required. + +**Response 200** +```json +{ + "ok": true, + "port": 8080, + "defaultModel": "minimax_api/MiniMax-M3", + "defaultWorkspace": "C:\\Users\\you\\.minimax-code\\webui", + "mcodeCmd": "C:\\Users\\you\\.minimax-code\\mcode.cmd", + "mcodeVersion": "0.1.2", + "maxConcurrent": 3 +} +``` + +--- + +## State & SSE + +### `GET /api/state` + +Returns the current `state` object for this CID. See +[ARCHITECTURE.md §4](ARCHITECTURE.md) for the full shape. + +**Response 200** +```json +{ "ok": true, "version": "0.1.3", "running": {"active": false}, … } +``` + +### `GET /api/events` + +Server-Sent Events stream for this CID. The connection stays open +indefinitely. Events are listed in +[ARCHITECTURE.md §5](ARCHITECTURE.md). + +**Response 200** (`Content-Type: text/event-stream`) +``` +event: state +data: {"version":"0.1.3","running":{"active":false},…} + +event: delta +data: {"text":"hello","isPartial":true} + +event: exec +data: {"status":"ok","durationMs":12345} +``` + +The connection is held open until the client closes it (`EventSource.close()`) +or the server shuts down. No automatic reconnect from the server side; +the webui handles reconnection with exponential backoff. + +--- + +## Chat + +### `POST /api/send` + +Send a user message. Spawns (or reuses) the mcode subprocess for this CID +and streams the result via SSE. + +**Request** +```json +{ + "content": "refactor the workspace picker to use a tree", + "attachments": ["@C:\\path\\to\\file.py"], + "isAskAnswer": false +} +``` + +- `content` (string, required) — the user message. May include `@path` + references to attachments; the webui injects these automatically. +- `attachments` (string[], optional) — list of `@path` strings to + prepend to the content. The webui populates this from the attachment + UI; you usually don't pass it directly. +- `isAskAnswer` (bool, optional) — when `true`, the content is the + answer to an active `ask_user` question. Set by the ask modal + automatically. + +**Response 200** `{ok: true}` immediately. The actual response is +streamed via `/api/events`. + +**Errors** +- 409 if `state.running.active === true` (already running) +- 400 if `content` is empty + +### `POST /api/stop` + +Cancel the current run. Best-effort: tries `session/cancel` via acp +(unimplemented in 0.1.5), then SIGTERM, then SIGKILL after 2s. + +**Request** `{}` + +**Response 200** `{ok: true}` + +### `POST /api/cmd` + +Send a raw slash command (e.g. `/compact`, `/clear`). The server sends +the command to mcode and streams the result. + +**Request** +```json +{ "cmd": "/compact" } +``` + +**Response 200** `{ok: true}` + +--- + +## Sessions + +### `GET /api/sessions` + +List webui sessions + mcode sessions (merged, deduplicated). + +**Response 200** +```json +{ + "ok": true, + "count": 12, + "sessions": [ + { "id": "uuid", "title": "…", "workspace": "C:\\…", "mcodeSessionId": "mvs_…", "updatedAt": 1234567890 } + ] +} +``` + +### `POST /api/sessions` + +Create a new webui session. Optionally tied to a workspace. + +**Request** +```json +{ "workspace": "C:\\path\\to\\project" } +``` + +**Response 200** `{ok: true, id: "uuid"}` + +### `POST /api/sessions/switch` + +Switch to an existing session. Loads its chat history and (if linked) +re-attaches to the mcode session. + +**Request** +```json +{ "id": "uuid" } +``` + +**Response 200** `{ok: true}` + +### `POST /api/sessions/cleanup-orphans` + +Delete mcode sessions that no webui session references. Two scopes: + +- `scope: "orphans"` (default) — only delete mcode sessions with no + webui reference. The currently-active session is always preserved. +- `scope: "all"` — delete every mcode session, then re-link webui + sessions that had a `mcodeSessionId` (which now points to a deleted + session — they become "webui-only" again). + +**Request** +```json +{ "scope": "orphans" } +``` + +**Response 200** +```json +{ + "ok": true, + "scope": "orphans", + "total": 37, + "targets": 18, + "deleted": 18, + "failed": 0, + "log": ["deleted mvs_5103ca…", "deleted mvs_88c796…", …] +} +``` + +### `DELETE /api/sessions/:id` + +Delete a webui session AND its linked mcode session (if any). The mcode +deletion is a transaction across 8 sqlite tables. + +**Response 200** `{ok: true}` + +### `GET /api/acp-sessions` + +Raw mcode session list (from sqlite). No webui merge. + +**Response 200** `{ok: true, sessions: [...]}` + +### `GET /api/acp-session-title?sessionId=mvs_…` + +Get the title of an mcode session. + +**Response 200** `{ok: true, title: "…"}` + +--- + +## Workspace + +### `POST /api/workspace` + +Change the workspace for the current CID. + +**Request** +```json +{ + "dir": "C:\\path\\to\\project", + "syncTui": true +} +``` + +- `dir` (string, required) — absolute path +- `syncTui` (bool, optional) — also write the path to `cwd.json` so the + mcode TUI sees it +- `action: "detect"` — instead of changing, return the current TUI cwd +- `action: "useTui"` — copy the TUI's cwd to webui +- `action: "reset"` — restore webui's default workspace + +**Response 200** `{ok: true, dir: "…", branch: "main", treeState: "clean"}` + +### `GET /api/workspace/browse?path=…` + +List a directory for the tree browser. + +**Request** query: `?path=C:\\Users` (omit for drive roots on Windows +or `/` for Linux) + +**Response 200** +```json +{ + "ok": true, + "path": "C:\\Users", + "children": [ + { "name": "Public", "path": "C:\\Users\\Public", "isDir": true } + ] +} +``` + +When `path` is omitted: +- Windows: `roots: ["C:", "D:", …]` +- Linux: `children: [{name: "/", path: "/", isDir: true}]` + +--- + +## Settings + +### `GET /api/settings` + +Returns the full settings snapshot. **This endpoint is exempt from +the LAN guard** — it's how a remote user toggles LAN back on after +locking themselves out. + +**Response 200** +```json +{ + "ok": true, + "lanBroadcast": true, + "port": 8080, + "host": "0.0.0.0", + "lanIp": "192.168.1.50", + "lanUrl": "http://192.168.1.50:8080", + "localUrl": "http://127.0.0.1:8080", + "mcodeCmd": "C:\\…\\mcode.cmd", + "mcodeVersion": "0.1.2", + "defaultWorkspace": "C:\\…", + "defaultModel": "minimax_api/MiniMax-M3" +} +``` + +### `POST /api/settings` + +Update one or more settings. Only `lanBroadcast` is currently settable. + +**Request** +```json +{ "lanBroadcast": false } +``` + +**Response 200** `{ok: true, lanBroadcast: false, …}` + +--- + +## Upload + +### `POST /api/upload` + +Multipart file upload. Saves to `MCODE_WEBUI_UPLOAD_DIR` and returns +the absolute path. + +**Request** `multipart/form-data` with a `file` field. + +**Response 200** +```json +{ + "ok": true, + "filename": "screenshot.png", + "path": "C:\\…\\.webui-uploads\\screenshot.png", + "size": 12345, + "mime": "image/png" +} +``` + +--- + +## Model + +### `GET /api/models` + +Returns the builtin + currently-configured model list. + +**Response 200** +```json +{ + "ok": true, + "current": "minimax_api/MiniMax-M3", + "models": [ + { "id": "minimax_api/MiniMax-M3", "label": "MiniMax-M3", "provider": "minimax_api" } + ] +} +``` + +If the list is empty, the response includes a `hint` field pointing +the user at the mcode TUI for model configuration. + +### `POST /api/set-model` + +Change the model for the current CID. + +**Request** +```json +{ "model": "minimax_api/MiniMax-M3" } +``` + +**Response 200** `{ok: true, model: "…"}` + +### `POST /api/permissions` + +Change the session-level permission mode. + +**Request** +```json +{ "permissions": "ask" } +``` + +- `permissions` (string) — one of `ask`, `auto`, `full`, `plan` + +**Response 200** `{ok: true, permissions: "ask"}` + +> Note: mcode 0.1.5 acp does not implement `session/set_mode`. The +> webui's UI shows the mode the user selected, but the underlying mcode +> session does not change. This is logged in the server console as +> `[mcode-rpc] UNSUPPORTED session/set_mode`. Will start working when +> mcode implements the method. + +### `GET /api/permissions-modes` + +List the available permission modes. + +**Response 200** `{ok: true, modes: ["default", "bypassPermissions", "auto", "off", "read", "full"]}` + +### `POST /api/answer` + +Respond to an active permission / plan / ask_user prompt. + +**Request** +```json +{ "type": "permission", "option": "ask" } +``` + +- `type` (string) — `permission` | `plan` | `planmode` | `ask` +- `option` (string) — depends on type: + - `permission`: `ask` | `auto` | `full` + - `plan`: `agree` | `skip` | `add` + - `planmode`: `continue` | `deny` + - `ask`: `esc` (skip) | `` (option) | `` (free-form) + +**Response 200** `{ok: true}` + +--- + +## Usage + +### `GET /api/usage` and `POST /api/usage` and `POST /api/usage-trigger` + +Fetch the current `mmx quota show` snapshot. `POST /api/usage-trigger` +also triggers a fresh fetch from the CLI. `GET /api/usage` and +`POST /api/usage` return the cached value if recent. + +**Response 200** +```json +{ + "ok": true, + "remaining": 91, + "resetAt": 1234567890, + "weeklyResetAt": 1234567890, + "fetchedAt": 1234567890, + "source": "mmx" +} +``` + +### `GET /api/usage-real` + +Fetch per-turn context usage from the `mavis` runtime db. This is the +source of truth for "已用 N / 占比 N%" in the right panel. + +**Response 200** +```json +{ + "ok": true, + "lastTurnContextTokens": 12345, + "lastInputTokens": 1000, + "lastCacheReadTokens": 500, + "lastCacheWriteTokens": 200, + "lastOutputTokens": 800, + "contextLimit": 524288, + "model": "MiniMax-M3", + "ts": 1234567890 +} +``` + +### `POST /api/refresh` + +Re-fetch quota + per-turn context. The webui calls this when the user +clicks the "刷新" button in the usage popover. + +**Response 200** `{ok: true}` + +--- + +## Protocol (acp shim) + +These endpoints wrap the acp protocol methods that the webui *can* +call. Methods that mcode 0.1.5 doesn't implement return 501 with +`{code: 'unsupported'}`. + +### `POST /api/protocol/set-mode` + +Calls `session/set_mode`. **Currently returns 501** (mcode 0.1.5). + +### `POST /api/protocol/set-config-option` + +Calls `session/set_config_option`. **Currently returns 501**. + +### `POST /api/protocol/cancel` + +Calls `session/cancel`. **Currently returns 501** (falls back to +SIGTERM on the subprocess). + +### `POST /api/protocol/load-session` + +Calls `session/load`. Works in 0.1.5. + +**Request** `{sessionId: "mvs_…", cwd: "C:\\…"}` + +### `POST /api/protocol/activate-session` + +Calls `session/activate`. **Currently returns 501**. + +### `GET /api/protocol/list-sessions` + +Calls `session/list`. Works in 0.1.5. + +### `GET /api/protocol/capabilities` + +Returns the list of acp methods the webui knows about and their +support status. Used by the webui to decide which UI controls to +enable. + +**Response 200** +```json +{ + "ok": true, + "agentInfo": { "name": "mcode", "title": "mcode", "version": "0.1.5" }, + "supported": ["session/new", "session/list", "session/load", "session/prompt", "session/close"], + "unsupported": ["session/set_mode", "session/set_config_option", "session/cancel", …] +} +``` + +--- + +## Debug (gated) + +### `POST /api/debug/inject` + +Inject a fake event into the SSE channel for a CID. Used for testing +the UI without a real mcode subprocess. + +**Request** +```json +{ "cid": "uuid", "type": "delta", "text": "hello" } +``` + +**Response 200** `{ok: true}` + +**Gating**: this endpoint only works if `DEBUG_INJECT=1` is set in the +server's environment. The server logs a warning every time it's +called. Production deployments should leave the env unset. + +### `GET /api/debug/state` + +Returns the full per-cid state including internal flags. Same +`DEBUG_INJECT` gating. + +--- + +## Static + +### `GET /` + +Returns `public/index.html`. + +### `GET /` + +Returns the file from `public/` if it exists. Served by `serveStatic`. +Cache headers: `public, max-age=3600`. The HTML/JS/CSS paths +embed a `?v=N` cache-bust query string; bump it in `index.html` when +you want clients to refetch. + +--- + +## Error responses + +All errors follow one of these shapes: + +```json +{ "ok": false, "error": "human-readable message" } +``` + +```json +{ "ok": false, "code": "unsupported", "error": "mcode 0.1.5 acp does not implement session/set_mode" } +``` + +```json +{ "ok": false, "error": "LAN 访问已关闭。在本机打开设置开启。" } +``` + +The HTTP status is appropriate to the cause (400 / 401 / 403 / 404 / 409 / 500 / 501). diff --git a/plugins/Wzdhehe/mcode-webui/docs/ARCHITECTURE.md b/plugins/Wzdhehe/mcode-webui/docs/ARCHITECTURE.md new file mode 100644 index 0000000..defaec3 --- /dev/null +++ b/plugins/Wzdhehe/mcode-webui/docs/ARCHITECTURE.md @@ -0,0 +1,383 @@ +# Architecture + +> Companion to [README.md](../README.md). This document is for people +> modifying the webui or integrating with it. It describes the runtime +> topology, the module boundaries, the request lifecycle, and the SSE +> payload contract. + +## 1. High-level topology + +``` + ┌─────────────────────────────────────────────┐ + │ Browser (public/) │ + │ • index.html (markup) │ + │ • app/main.js (ES module) │ + │ • styles/main.css │ + └─────────────────────────────────────────────┘ + │ ▲ │ ▲ + fetch / JSON │ │ EventSource / SSE │ │ + ▼ │ ▼ │ + ┌──────────────────────────────────────────────────────────────────────┐ + │ server.js — bootstrap only (≈ 100 lines) │ + │ • installGlobalErrorHandlers() │ + │ • preflight: mcode.cmd exists, upload dir writable, etc. │ + │ • http.createServer(handleRequest) │ + └──────────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────────────────────────────┐ + │ server/router.js — declarative route table │ + │ │ + │ LAN guard: !isLocalRequest(req) && !getLanBroadcast() → 403 │ + │ │ + │ ┌─ static ┐ ┌─ /api/health ┐ ┌─ /api/state ┐ ┌─ /api/sessions ┐ │ + │ │ index │ │ health.js │ │ state.js │ │ sessions.js │ │ + │ │ .html │ └───────────────┘ │ + /api/events│ │ + acp- │ │ + │ │ .css/js │ │ (SSE) │ │ sessions/* │ │ + │ │ .png │ └──────────────┘ └────────────────┘ │ + │ └─────────┘ │ + │ ┌─ /api/send ┐ ┌─ /api/usage ┐ ┌─ /api/workspace ┐ │ + │ │ chat.js │ │ usage.js │ │ workspace.js │ │ + │ │ + /stop /cmd │ │ + -real │ │ + /workspace/ │ │ + │ │ │ │ + /refresh │ │ browse │ │ + │ └──────────────┘ └──────────────┘ └──────────────────┘ │ + │ ┌─ /api/upload ┐ ┌─ /api/settings ┐ ┌─ /api/models ┐ │ + │ │ upload.js │ │ settings.js │ │ model.js │ │ + │ └──────────────┘ └─────────────────┘ │ + /set-model │ │ + │ │ + /permissions │ │ + │ │ + /answer │ │ + │ └──────────────────┘ │ + │ ┌─ /api/protocol/* ┐ ┌─ /api/debug/* ┐ │ + │ │ protocol.js │ │ debug.js │ │ + │ │ /set-mode │ │ /inject (gated)│ │ + │ │ /set-config-option│ │ /state │ │ + │ │ /cancel │ └────────────────┘ │ + │ │ /load-session … │ │ + │ │ /capabilities │ │ + │ └───────────────────┘ │ + └──────────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────────────────────────────┐ + │ server/lib/ — pure modules (one concern each) │ + │ │ + │ config · lan · models · db · sessions │ + │ state-bus · acp-client · mcode-rpc · mcode-acp · mcode-exec │ + │ mavis-usage · usage · settings · upload · workspace · slash │ + └──────────────────────────────────────────────────────────────────────┘ + │ ▲ + ▼ │ JSON-RPC over stdio + ┌──────────────────────────────────────┐ ┌─────────────────────────────┐ + │ mcode exec subprocess │ │ mcode acp subprocess │ + │ (legacy single-turn, fallback) │ │ (default multi-turn) │ + │ stdio: line-delimited stream-json │ │ stdio: newline-delimited │ + │ │ │ JSON-RPC 2.0 │ + └──────────────────────────────────────┘ └─────────────────────────────┘ +``` + +## 2. Request lifecycle + +A user clicks **Send**. The events that follow: + +``` +browser server/router.js server/lib/* mcode + │ POST /api/send {content,…} │ │ + │ ──────────────────────────────►│ │ + │ │ chat.js: validate, │ + │ │ cs = getClient(cid) │ + │ │ cid → state-bus │ + │ │ ─────────────────► │ + │ │ │ mcode-acp.js / mcode-exec.js + │ │ │ ─── spawn / pipe stdin ───► + │ │ │ + │ │ state-bus: pushStateFor(cid) │ + │ ◄──────────── SSE event ────│ {type:'state', running:…} │ + │ {type:'chat', lines:[…]} │ │ + │ ◄──────────── SSE event ────│ ◄── line ◄─── stdout ────│ + │ {type:'delta', text:'…'} │ │ + │ … │ │ + │ ◄──────────── SSE event ────│ ◄── exec.result ──────────│ + │ {type:'exec', status:'ok'} │ │ + │ ◄──────────── SSE event ────│ │ + │ {type:'state', running:false}│ │ + │ … │ │ + │ connection closes / kept open │ │ +``` + +Key invariants: + +- **One `mcode` subprocess per active webui tab** (keyed by `cid` = + client id, a UUID stored in `localStorage.webui_cid`). A new tab gets a new + subprocess; a closed tab kills its subprocess. State is per-cid, not + per-connection. +- **The SSE channel is the only source of state updates** for the client. + REST endpoints mutate server state but do not push to the client. The + client treats SSE as truth. +- **`pushStateFor(cid, opts)` is the only function that mutates per-cid + state on the server.** Everything else is read-only. This is why + `state-bus.js` is the size it is — it's the single chokepoint. + +## 3. Module contracts + +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: `PORT`, `HOST`, `MCODE_ROOT`, `MCODE_CMD`, + `DEFAULT_MODEL`, `DEFAULT_WORKSPACE`, `DEFAULT_TIMEOUT`, + `MCODE_RUNTIME_DB`, `MAVIS_DB_PATH`. +- Reads `process.env.*` exactly once at module load. No per-request + re-reading. +- `installGlobalErrorHandlers()` writes uncaught exceptions to + `.server.err` so they survive a process restart. + +### `state-bus.js` +The chokepoint. Exports: + +| Function | Purpose | +|---|---| +| `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`. | +| `pushEvent(cid, event)` | Append an arbitrary event to the SSE channel (`{type, …}`). | +| `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 `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: + +- `McodeAcpClient` class — `start()`, `request(method, params)`, + `notify(method, params)`, `stop()`, `events` EventEmitter. +- `getMcodeAcpClient()` — process-wide singleton. Init is + `pInitPromise` de-duplicated so concurrent `start()` callers share a + single subprocess. +- Cache: `mcodeCommandsCache` and `mcodeSessionsCache` avoid + repeated JSON-RPC round-trips for `session/list` and + `session/commands`. + +### `mcode-rpc.js` +The shim for methods mcode 0.1.5 does not implement: + +```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', +]) +``` + +`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. + +### `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. + +Both expose: +- `runMcode(content, opts)` → `AsyncGenerator` +- `stopExec()` → `void` +- `isRunning()` → `boolean` + +`NormalizedEvent` is a tagged union (`{type, …}`) with these types: +`state`, `chat`, `delta`, `tool`, `permission`, `plan`, `ask`, +`exec`, `usage`. See § 5. + +## 4. The `clientState.state` payload + +This is the shape every SSE `state` event contains. The webui mirrors +it 1:1 into the `state` JS variable. + +```ts +{ + version: string, // webui version (from package.json) + running: { active: boolean, + sessionId?: string, // mcode acp session id (if any) + cid: string, // webui tab id + startTime?: number, // ms epoch + pendingPermission?: object, + pendingPlan?: object, + pendingAsk?: object }, + workspace: { dir: string, // absolute path, "" if unset + branch?: string, // git branch (best-effort, "" on error) + treeState?: 'clean'|'dirty'|'unknown' }, + model: { name: string, // e.g. "minimax_api/MiniMax-M3" + ctx: string, // e.g. "512k" + thinking: 'On'|'Off'|string }, + permissions: string, // mcode-side: 'ask'|'auto'|'full'|'plan'|... + commands: Array<{ // mcode slash commands + cmd: string, zh: string, en: string, + description_zh?: string, description_en?: string, + hint?: string, + input_hint?: string, + destructive?: boolean }>, + sessions: Array<{ // webui-side session list (merged w/ mcode) + id: string, + title: string, + workspace: string, + mcodeSessionId?: string, // linked mcode session id + updatedAt: number }>, + mcodeSessions: Array<{ // mcode-side session list (raw) + sessionId: string, + title: string, + cwd: string, + updatedAt: number }>, + mcodeSessionId?: string, // currently-active mcode session + context?: { // updated by SSE delta accumulation + used: number, // tokens used (per-turn) + percent: number, // 0..100 + cacheRead: number, // per-turn cache reads + tps: number, // current tok/s + source: 'mavis'|'mmx' }, // which backend provided the data + usage?: { // from /api/usage + remaining: number, // percent + resetAt: number, // ms epoch + weeklyResetAt: number, + fetchedAt: number }, + plan?: { active: boolean, title: string, summary: string, + options: Array<{label:string}>, totalLines: number, + summaryLines: number }, + enterPlanMode?: { active: boolean }, + permissionChoice?: { active: boolean, current: string, + options: Array<{label:string}> }, + askUser?: { active: boolean, questions: Array<…> }, + goal?: { active: boolean, text: string, status: 'running'|'done'|'blocked', + duration?: number }, + todo?: Array<{ content: string, status: 'pending'|'in_progress'|'done' }>, + lanBroadcast: boolean, // mirrors /api/settings + onlineCount: number // from pushOnlineCount +} +``` + +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. SSE event schema + +``` +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}]} + +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} +``` + +The webui treats each event as an idempotent update; replaying the +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/plugins/Wzdhehe/mcode-webui/public/lib/marked.min.js b/plugins/Wzdhehe/mcode-webui/public/lib/marked.min.js new file mode 100644 index 0000000..a91afe7 --- /dev/null +++ b/plugins/Wzdhehe/mcode-webui/public/lib/marked.min.js @@ -0,0 +1,6 @@ +/** + * 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/plugins/Wzdhehe/mcode-webui/public/styles/main.css b/plugins/Wzdhehe/mcode-webui/public/styles/main.css new file mode 100644 index 0000000..8cf24f0 --- /dev/null +++ b/plugins/Wzdhehe/mcode-webui/public/styles/main.css @@ -0,0 +1,2866 @@ +/* ============================================================ + 主题变量 — 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 — 顶栏可点击切换工作区(用 ` + + `` + // Click handlers are event-delegated on the row (see events.js), + // so the new buttons pick them up automatically — no re-bind needed. + } + } + + // acknowledged 提示 + if (lanCardTokenWarning) lanCardTokenWarning.hidden = s.tokenAcknowledged !== false + if (lanCardTokenAck) lanCardTokenAck.hidden = s.tokenAcknowledged !== false +} + // v0.5.bx-31: sidebar 首次 SSE 推 mcodeSessions 之前显示 skeleton, 避免点删除/切时 race // mcode acp singleton 启动要 1-3s, 期间 state.mcodeSessions=[] → render 显示空 // 用户在空 sidebar 上点删除 webui entry, mcode db 的对应 session 没删 → SSE 推过来时"又出现" diff --git a/plugins/Wzdhehe/mcode-webui/public/app/state.js b/plugins/Wzdhehe/mcode-webui/public/app/state.js index 2550e32..59522f8 100644 --- a/plugins/Wzdhehe/mcode-webui/public/app/state.js +++ b/plugins/Wzdhehe/mcode-webui/public/app/state.js @@ -14,10 +14,56 @@ import { SLASH_COMMANDS, SLASH_SKILLS, attachEvents, attachModalEvents, attached // ============================================================ // Config // ============================================================ +// v1.0.1: token 持久化 + URL strip +// 1. 优先用 URL query 里的 ?token=(用户从带 token 的链接进来) +// 2. fallback 到 localStorage(reload / 新 tab 时还在;F5 后 URL 没 token +// 也不会立刻 401) +// 3. 拿到 token 后立刻 history.replaceState 把 ?token= 从 URL 抹掉, +// 避免 token 长期留在地址栏、浏览器 history、Referer header +// 4. 同步写到 localStorage,下次启动继续用 +// +// 注意: token 不能 log, 不能 echo back, 不能进 URL fragment, 不能进 +// 任何 SSE / API 的 log。SECURITY-NOTES.md §2 完整说明了 trade-off。 +const WEBUI_TOKEN_LS_KEY = 'webui_token' + +function readToken() { + // 1. URL ?token= takes precedence (user opening a link) + const fromUrl = urlParams.get('token') + if (fromUrl) { + try { localStorage.setItem(WEBUI_TOKEN_LS_KEY, fromUrl) } catch {} + return fromUrl + } + // 2. localStorage fallback (F5, new tab, deep-link without token) + try { + const fromLs = localStorage.getItem(WEBUI_TOKEN_LS_KEY) + if (fromLs) return fromLs + } catch {} + return '' +} + +// URL strip — must run exactly once at module load, before any +// fetch / EventSource is created (so the address bar is clean and +// the browser never sends the token via Referer to same-origin assets). +function stripTokenFromUrl() { + if (!urlParams.has('token')) return + try { + const clean = window.location.pathname + (window.location.hash || '') + window.history.replaceState(null, '', clean) + } catch { + // private mode etc. — token is still in localStorage so reload works + } +} + export const urlParams = new URLSearchParams(window.location.search) -export const tokenParam = urlParams.get('token') || '' -export const TOKEN = tokenParam // 给 fetch/SSE 用 -export const TOKEN_QUERY = TOKEN ? `?token=${encodeURIComponent(TOKEN)}` : '' +export let TOKEN = readToken() +stripTokenFromUrl() // must run after readToken(), before any fetch/SSE +export let TOKEN_QUERY = TOKEN ? `?token=${encodeURIComponent(TOKEN)}` : '' + +// Back-compat: events.js + render.js still import `tokenParam` from +// earlier versions. It's an alias for TOKEN_QUERY (same semantics). +// Kept as a deprecated export to avoid breaking older code that may +// have been depending on it. New code should use TOKEN_QUERY directly. +export const tokenParam = TOKEN_QUERY // v0.5.ai: A2 per-client — 每个 webui tab 一个 client id (localStorage 持久化) // 拼到所有 /api/xxx URL query string,server 端按 cid 路由 SSE + state @@ -31,9 +77,38 @@ export const CID = (() => { })() export const CID_QUERY = `cid=${encodeURIComponent(CID)}` // API_SUFFIX = TOKEN_QUERY (if any) + '&cid=xxx' (or '?cid=xxx' first) -export const API_SUFFIX = TOKEN_QUERY ? `${TOKEN_QUERY}&${CID_QUERY}` : `?${CID_QUERY}` +export let API_SUFFIX = TOKEN_QUERY ? `${TOKEN_QUERY}&${CID_QUERY}` : `?${CID_QUERY}` + +// v1.0.1: HEADERS is a live object — its properties are mutated in place +// when the token rotates (SSE auth.token_rotated event). All callers use +// the object reference (not a snapshot) so they always read the current +// Authorization header at fetch time. Tokens are NEVER logged (per +// SECURITY-NOTES.md §2). +export const HEADERS = {} +if (TOKEN) HEADERS['Authorization'] = `Bearer ${TOKEN}` -export const HEADERS = TOKEN ? { 'Authorization': `Bearer ${TOKEN}` } : {} +// setToken — called by the SSE handler when server pushes a new token +// (auth.token_rotated). Updates module-level state + localStorage + +// recomputes the URL query suffix. The next fetch() call automatically +// picks up the new header (HEADERS is a live binding). +export function setToken(newToken) { + const t = (typeof newToken === 'string') ? newToken : '' + TOKEN = t + TOKEN_QUERY = t ? `?token=${encodeURIComponent(t)}` : '' + API_SUFFIX = TOKEN_QUERY ? `${TOKEN_QUERY}&${CID_QUERY}` : `?${CID_QUERY}` + // Mutate the headers object in place (live binding — all importers + // see the new Authorization header on their next fetch) + if (t) { + HEADERS['Authorization'] = `Bearer ${t}` + } else { + delete HEADERS['Authorization'] + } + // Persist for next reload (covers rotation while the page is open) + try { + if (t) localStorage.setItem(WEBUI_TOKEN_LS_KEY, t) + else localStorage.removeItem(WEBUI_TOKEN_LS_KEY) + } catch {} +} // ============================================================ // State @@ -55,6 +130,19 @@ export function connect() { if (es) { try { es.close() } catch {} } const url = '/api/events' + API_SUFFIX es = new EventSource(url) + + // v1.0.1: named event "auth.token_rotated" — server pushes this when + // an operator triggers a token rotation. The body is plain text + // (the new token) — we use it to update localStorage + live HEADERS. + es.addEventListener('auth.token_rotated', (ev) => { + try { + const newToken = (ev.data || '').trim() + if (!newToken) return + setToken(newToken) + console.log('[webui] token rotated (SSE); updated HEADERS + localStorage') + } catch (e) { console.error('[webui] token rotation handler failed', e) } + }) + es.onmessage = (ev) => { try { // v0.5.bx-8: 保留 askUserAnswers (webui-only, server 不存) — SSE 推送整 state 会覆盖 diff --git a/plugins/Wzdhehe/mcode-webui/public/brand-logo.png b/plugins/Wzdhehe/mcode-webui/public/brand-logo.png index 0f512497c30ac23a2c6d795e10f80e68bfdc4a7d..fa5403c6051ea259523a0a9e5166dc7bee6d76dd 100644 GIT binary patch delta 68 zcmdm@x<{3#Gr-TCmrII^fq{W{BUc|UBk$xHym>&9jqf9n{LU8(Bt!U18F@FK delta 55 zcmdm^x🟢 1 台 + + diff --git a/plugins/Wzdhehe/mcode-webui/public/styles/main.css b/plugins/Wzdhehe/mcode-webui/public/styles/main.css index 8cf24f0..24a28bd 100644 --- a/plugins/Wzdhehe/mcode-webui/public/styles/main.css +++ b/plugins/Wzdhehe/mcode-webui/public/styles/main.css @@ -292,6 +292,26 @@ a.chip-lan-link .icon { .chip-workspace:hover { background: var(--bg-hover, rgba(0,0,0,0.04)); border-color: var(--accent); } .chip-workspace:active { background: var(--bg-active, rgba(0,0,0,0.08)); } .chip-workspace-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* v1.0.1: 只读模式 chip — 红色背景 + 白字, 双语 ("只读 / READ ONLY") + 醒目的视觉提示, 让任何 client (本机 / 远程) 都能一眼看到当前 + webui 处于只读模式. */ +.chip-readonly { + background: var(--danger); + color: #fff; + border: 1px solid var(--danger); + font-weight: 600; + letter-spacing: 0.3px; + animation: pulse-readonly 2.4s ease-in-out infinite; +} +.chip-readonly-sep { + opacity: 0.6; + font-weight: 400; + margin: 0 -2px; +} +@keyframes pulse-readonly { + 0%, 100% { box-shadow: 0 0 0 0 rgba(var(--danger-rgb, 192, 57, 43), 0.4); } + 50% { box-shadow: 0 0 0 6px rgba(var(--danger-rgb, 192, 57, 43), 0); } +} /* v0.5.al: workspace picker popover(fixed 定位,跟随 chip-workspace) */ .workspace-picker { @@ -2509,6 +2529,9 @@ body.is-remote .btn-lan-toggle { display: none; } .chat-area { width: 100%; } .btn-mobile-toggle { display: inline-flex !important; } .topbar-status { display: none; } + /* v1.0.1: 移动端也必须显示只读 chip (read-only 是关键安全提示, 不能因为 + viewport 小就藏起来让用户漏掉) */ + .topbar-status .chip-readonly { display: inline-flex; } } @media (max-width: 600px) { .chat-inner { padding: 0 12px; } @@ -2864,3 +2887,208 @@ body.is-remote .btn-lan-toggle { display: none; } content: '⚡'; margin-right: 4px; } + +/* ============================================================ + v1.0.1: LAN sub-card (二级卡片) + 点开 #chip-lan 后展开;含只读/Token 鉴权/接口过滤等 + ============================================================ */ +.lan-card { + /* v1.0.1: popover 模式 — 浮在 sidebar 右边, 不挤压下方 GitHub 链接 */ + position: fixed; + left: 248px; /* left-panel 宽度 (240) + 8px gap */ + bottom: 70px; /* 离底部 GitHub 链接留点空间 */ + width: 320px; + max-height: calc(100vh - 100px); + overflow-y: auto; + padding: 12px 14px 14px 14px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow-lg); + font-size: 12.5px; + color: var(--text); + z-index: 100; +} +/* v1.0.1: 移动端 sidebar 折叠时, sub-card 不能还按 248px 左边距定位 (会跑出屏幕) + fallback: 占满 viewport, 留 16px 边距 */ +@media (max-width: 600px) { + .lan-card { + left: 16px; + right: 16px; + width: auto; + bottom: 60px; + max-height: calc(100vh - 80px); + } + /* 折叠的 sidebar 状态下, 卡片不显示指向 sidebar 的三角箭头 (没意义) */ + .lan-card::before { display: none; } +} +/* 小三角箭头指向 chip-lan (在卡片左下) */ +.lan-card::before { + content: ""; + position: absolute; + left: -7px; + bottom: 24px; + width: 12px; + height: 12px; + background: var(--bg-elevated); + border-left: 1px solid var(--border); + border-bottom: 1px solid var(--border); + transform: rotate(45deg); +} +.lan-card[hidden] { display: none; } +.lan-card-title { + font-weight: 600; + font-size: 12px; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; +} +.lan-card-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--border-light); +} +.lan-card-row:last-of-type { border-bottom: none; } +.lan-card-row-label { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; + cursor: pointer; +} +.lan-card-row-label > span:first-child { + font-weight: 500; + color: var(--text); +} +.lan-card-row-help { + font-size: 11px; + color: var(--text-tertiary); + line-height: 1.4; +} +.lan-card-toggle { + position: relative; + display: inline-block; + width: 36px; + height: 20px; + flex-shrink: 0; +} +.lan-card-toggle input { opacity: 0; width: 0; height: 0; } +.lan-card-toggle-slider { + position: absolute; + cursor: pointer; + top: 0; left: 0; right: 0; bottom: 0; + background: var(--border); + border-radius: 20px; + transition: background 0.2s; +} +.lan-card-toggle-slider::before { + content: ""; + position: absolute; + height: 14px; width: 14px; + left: 3px; bottom: 3px; + background: var(--bg-elevated); + border-radius: 50%; + transition: transform 0.2s; +} +.lan-card-toggle input:checked + .lan-card-toggle-slider { background: var(--status-on); } +.lan-card-toggle input:checked + .lan-card-toggle-slider::before { transform: translateX(16px); } +.lan-card-toggle input:focus + .lan-card-toggle-slider { box-shadow: 0 0 0 2px var(--accent-bg); } +.lan-card-section { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-light); +} +.lan-card-section-label { + font-weight: 600; + font-size: 12px; + color: var(--text); + margin-bottom: 6px; +} +.lan-card-token-row { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.lan-card-token-mask, +.lan-card-token-value { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--text-secondary); + background: var(--bg-input); + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--border-light); + word-break: break-all; + flex: 1; + min-width: 0; +} +.lan-card-token-value { + color: var(--text); +} +.lan-card-token-placeholder { + font-size: 12px; + color: var(--text-secondary); + padding: 4px 8px; + background: var(--bg-input); + border: 1px solid var(--border-light); + border-radius: 4px; + flex: 1; + text-align: left; +} +.lan-card-btn { + font-size: 11.5px; + padding: 4px 10px; + border-radius: 5px; + border: 1px solid var(--border); + background: var(--bg-elevated); + color: var(--text); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.lan-card-btn:hover { background: var(--bg-hover); border-color: var(--border); } +.lan-card-btn-primary { + background: var(--accent); + color: var(--on-accent); + border-color: var(--accent); +} +.lan-card-btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } +.lan-card-btn-danger { + color: var(--danger); + border-color: var(--danger); +} +.lan-card-btn-danger:hover { background: var(--danger); color: var(--on-accent); } +.lan-card-warning { + margin-top: 8px; + padding: 8px 10px; + background: var(--accent-bg); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 11.5px; + color: var(--text); + line-height: 1.5; +} +.lan-card-warning::before { + content: '⚠ '; + margin-right: 2px; +} +.lan-card-actions { + margin-top: 8px; + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.lan-card-help { + margin-top: 6px; + font-size: 10.5px; + color: var(--text-tertiary); + line-height: 1.4; +} +/* chip-lan 展开态 — chevron 旋转提示 */ +.btn-lan-toggle[aria-expanded="true"] .btn-chevron { transform: rotate(90deg); } +.btn-lan-toggle .btn-chevron { transition: transform 0.2s; } diff --git a/plugins/Wzdhehe/mcode-webui/references/SECURITY-NOTES.md b/plugins/Wzdhehe/mcode-webui/references/SECURITY-NOTES.md index 287f54f..1fc7bd6 100644 --- a/plugins/Wzdhehe/mcode-webui/references/SECURITY-NOTES.md +++ b/plugins/Wzdhehe/mcode-webui/references/SECURITY-NOTES.md @@ -199,16 +199,113 @@ log + a disabled feature) — it does not crash. ## 7. Testing & reproducibility - `npm test` runs `node --experimental-test-module-mocks --test test/*.test.js`. - 261 passing tests, 1 skipped, 0 failing on a clean checkout. + 371 passing tests, 1 skipped, 0 failing on a clean checkout. - `npm run lint` — ESLint flat config, 0 warnings on a clean checkout. - All tests use **temp file fixtures** (`mkdtempSync`). No test writes - to the user's real `~/.minimax/` directory unless `MCODE_RUNTIME_DB` - env is explicitly overridden. + to the user's real `~/.minimax/` or `~/.mcode-webui/` directory unless + `MCODE_RUNTIME_DB` / `MCODE_WEBUI_SETTINGS_PATH` env is explicitly + overridden. - Cross-platform: tests pass on Windows + Linux + macOS (CI matrix Node 22 + 24). --- +## 9. v1.0.1 — LAN sub-card: read-only / token auth + +v1.0.1 adds a secondary card under the `LAN access` chip in the bottom-left +panel. It centralizes the three most-relevant security / access controls: + +| Control | What it does | Where the state lives | +|---|---|---| +| **LAN access** (toggle) | On/off for the 403 gate on non-local requests (unchanged from v0.5.ap) | In-memory only; resets to `true` on restart (intentional — admins shouldn't get locked out) | +| **Read-only mode** (toggle) | When on, non-local `POST` / `DELETE` to `/api/*` return 403 `{error: "read-only mode"}`. `GET`, `HEAD`, `OPTIONS` are exempt. Local requests are always exempt. `/api/settings` is exempt (escape hatch) | Persisted to `~/.mcode-webui/settings.json` | +| **Token auth** (toggle) | When on, non-local requests must carry `?token=` or `Authorization: Bearer`. When off, the gate is bypassed even if a token is set (LAN-only deployment mode) | Persisted | +| **Token value + reset** | First-run: server generates a 32-hex-char token (`crypto.randomBytes(16).toString('hex')`) and writes it to `~/.mcode-webui/settings.json`. The token is **printed to stdout exactly once at first start** (not to `.server.log`). The settings card shows the token until the operator clicks "我已保存" (acknowledge). After acknowledgment, the server stops sending the token in `GET /api/settings` responses — only already-connected clients keep it. `Reset token` generates a new value, persists, broadcasts an `auth.token_rotated` SSE event so other connected clients update their `localStorage` + `Authorization` header live, and resets `tokenAcknowledged` to `false` (the new token is shown again). | Persisted to `~/.mcode-webui/settings.json` (mode 0600, atomic write via `.tmp` + rename) | + +### 9.1 Token resolution priority (per request) + +1. `process.env.TOKEN` (highest — escape hatch for `docker run -e TOKEN=...` deploys) +2. In-memory `expectedToken` synced from `settings.js` after `init()` / `rotateToken()` +3. Static `TOKEN` from `config.js` (fallback for tests) + +If all three are empty, the token-auth gate is **fail-open** (back-compat with +the v1.0.1 "loopback-only / trusted LAN" deployment). To force-fail-secure +on first run, set `TOKEN=` in the environment. + +### 9.2 Token storage + +- `~/.mcode-webui/settings.json` (mode 0600 on Unix; best-effort on Windows). +- File is **excluded from the webui process's standard logs** — `console.log` + on first start is the only place the token is printed. +- Backup-on-corruption: if the file fails JSON.parse, the server renames it + to `settings.json.bak` and starts with defaults (logs a warning). +- Override path for tests / non-default installs: + `MCODE_WEBUI_SETTINGS_PATH=/some/other/settings.json`. + +### 9.3 Token rotation — SSE `auth.token_rotated` + +When the operator hits "Reset token" in the UI: + +1. `POST /api/settings {resetToken: true}` (must already be authenticated) +2. Server generates new 32-hex token, writes to disk +3. Server broadcasts `event: auth.token_rotated\ndata: \n\n` to + every connected SSE client (the connection is already authenticated, + so the token in cleartext over SSE is no worse than the periodic state + push that also includes `currentToken` for the same window). +4. Server also broadcasts a regular state push (`currentToken` will be in + the JSON body until the operator clicks "我已保存"). +5. Clients that receive the SSE event update their `localStorage` and the + live `HEADERS.Authorization` object in place — subsequent `fetch` calls + automatically use the new token. +6. Clients on the old token that didn't get the SSE event (offline, etc.) + will see 401 on their next request and need to manually re-open with + the new token URL. + +### 9.4 `currentToken` in `/api/settings` responses + +- Returned **only when `tokenAcknowledged === false`**. +- After the operator clicks "我已保存", the server omits the token from + subsequent responses. This is a deliberate trade-off: clients that lost + their `localStorage` (e.g. cleared browser data) will need to either + trigger a rotation (operator-visible) or look up the token in + `~/.mcode-webui/settings.json`. +- A programmatic / CI caller that needs the token should call + `POST /api/settings {resetToken: true}` to force a rotation and then + read the response's `currentToken`. + +### 9.5 Files added / modified in v1.0.1 + +- **NEW** `server/lib/settings.js` (substantially rewritten) — owns + persistent settings + token generation + interface lookup. +- **NEW** `server/lib/auth.js` — adds `setExpectedToken`, + `setTokenAuthEnabled`. Per-request token check still happens here. +- `server/lib/lan.js` — no change in v1.0.1 (kept the existing + `detectLanIp` / `isLocalRequest` / `LAN_IP`). +- `server/router.js` — adds read-only gate (in addition to the existing + LAN and token gates). Interface-allowlist gate was prototyped in + v1.0.1 but removed before release per PR #16 reviewer scope. +- `server/routes/settings.js` — accepts new fields, handles rotation. +- `server/lib/state-bus.js` — adds `broadcastTokenRotated`; SSE state + push now includes `readOnly`, `tokenEnabled`, `currentToken` (when + not acknowledged), `tokenAcknowledged`, `tokenRotatedAt`. +- `public/app/state.js` — `HEADERS` is now a live-mutable object; + new `setToken()` + SSE `auth.token_rotated` handler. +- `public/app/render.js` — `renderLanCardContent(settings)` exported. +- `public/app/events.js` — `#chip-lan` click toggles the sub-card + (was: directly toggled `lanBroadcast`); new handlers for each control + inside the card. +- `public/index.html` — ` - + + + + + + + + @@ -576,7 +628,7 @@
    @@ -595,7 +647,7 @@ - +
    diff --git a/plugins/Wzdhehe/mcode-webui/public/styles/main.css b/plugins/Wzdhehe/mcode-webui/public/styles/main.css index 24a28bd..d0ecd0d 100644 --- a/plugins/Wzdhehe/mcode-webui/public/styles/main.css +++ b/plugins/Wzdhehe/mcode-webui/public/styles/main.css @@ -926,6 +926,11 @@ body.is-remote .btn-lan-toggle { display: none; } /* v0.5.z: 套餐用量按钮 + 右侧弹层(mmx quota + 本机时间自算) */ .btn-menu#btn-usage { position: relative; } .btn-menu#btn-usage[aria-expanded="true"] { background: var(--bg-hover); } +/* v2026-08-28 modacker: when quota feature is off (no Subscription + Key set, or quotaEnabled=false in settings), the entire button + is hidden — not just a "—", but gone. The popover is hidden by + the same toggle on its parent renderUsage() call. */ +.btn-menu#btn-usage.usage-hidden { display: none; } .usage-popover { position: absolute; bottom: 0; @@ -986,6 +991,241 @@ body.is-remote .btn-lan-toggle { display: none; } .usage-error { color: var(--danger); padding: 4px 0; line-height: 1.5; word-break: break-word; } .usage-empty { color: var(--text-muted); padding: 4px 0; font-style: italic; } +/* v2026-08-28 modacker: 套餐用量 popover 顶部嵌入 key 配置区 */ +.usage-popover-config { + padding: 4px 0 6px; +} +.usage-popover-config-title { + font-weight: 600; + font-size: 12px; + color: var(--text); + margin-bottom: 6px; +} +.usage-popover-config-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} +.usage-popover-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + user-select: none; + font-size: 12px; + color: var(--text); +} +.usage-popover-toggle input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; +} +.usage-popover-toggle-label { line-height: 1; } +.usage-popover-key-input { + flex: 1; + min-width: 0; + background: var(--bg-input, var(--bg-card)); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + font-family: var(--font-mono, monospace); + font-size: 12px; +} +.usage-popover-save-btn, +.usage-popover-clear-btn { + background: var(--bg-card); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + cursor: pointer; +} +.usage-popover-save-btn:hover { background: var(--bg-hover); } +.usage-popover-clear-btn:hover { background: var(--bg-hover); color: var(--danger); } +.usage-popover-status { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; + word-break: break-all; +} +.usage-popover-divider { + height: 1px; + background: var(--border); + margin: 6px 0 4px; +} + +/* v2026-08-28 modacker: API Key 状态按钮(底部,绿/红) */ +.api-key-status { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + padding: 6px 10px; + font-size: 12px; + cursor: pointer; + text-align: left; + color: var(--text); + transition: background 0.15s, border-color 0.15s; +} +.api-key-status:hover { background: var(--bg-hover); } +.api-key-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.api-key-status.configured { + border-color: var(--success, #16a34a); + color: var(--success, #16a34a); +} +.api-key-status.configured .api-key-status-dot { + background: var(--success, #16a34a); + box-shadow: 0 0 6px var(--success, #16a34a); +} +.api-key-status.not-configured { + border-color: var(--danger, #dc2626); + color: var(--danger, #dc2626); +} +.api-key-status.not-configured .api-key-status-dot { + background: var(--danger, #dc2626); + box-shadow: 0 0 6px var(--danger, #dc2626); +} +.api-key-status-text { flex: 1; } + +/* v2026-08-28 modacker: API Key 配置模态框 */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} +.modal-overlay[hidden] { display: none; } +.modal-card { + background: var(--bg-card, var(--bg-sidebar)); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + min-width: 360px; + max-width: 480px; + width: 90%; + padding: 0; + display: flex; + flex-direction: column; +} +.modal-card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} +.modal-card-title { + font-weight: 600; + font-size: 14px; +} +.modal-card-close { + background: transparent; + border: 0; + color: var(--text-muted); + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} +.modal-card-close:hover { color: var(--text); } +.modal-card-body { + padding: 12px 16px; +} +.modal-card-status { + font-size: 12px; + margin-bottom: 10px; + word-break: break-all; +} +.modal-card-status.configured { color: var(--success, #16a34a); } +.modal-card-status.not-configured { color: var(--danger, #dc2626); } +.modal-card-label { + display: block; + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 4px; +} +.modal-card-input { + width: 100%; + background: var(--bg-input, var(--bg-card)); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: 6px 8px; + font-family: var(--font-mono, monospace); + font-size: 12px; + box-sizing: border-box; +} +.modal-card-input:focus { + outline: none; + border-color: var(--accent, #3b82f6); +} +/* v2026-08-28 modacker: Subscription Key input — visually password-like + but semantically type="text" (so the browser's password autofill + pipeline doesn't classify the entire page as a credential form + and start injecting emails/usernames into every other text input). + `-webkit-text-security: disc` renders each char as a bullet point + on Chrome/Safari; Firefox falls back to monospace + letter-spacing + (still readable as a masked secret, just not bullet-masked). */ +.modal-card-input.secret-input, +#api-key-modal-input.secret-input { + font-family: var(--font-mono, monospace); + letter-spacing: 2px; + -webkit-text-security: disc; +} +.modal-card-help { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; + line-height: 1.4; +} +.modal-card-actions { + display: flex; + justify-content: flex-end; + gap: 6px; + padding: 12px 16px; + border-top: 1px solid var(--border); +} +.modal-card-btn { + background: var(--bg-card); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: 5px 12px; + font-size: 12px; + cursor: pointer; +} +.modal-card-btn:hover { background: var(--bg-hover); } +.modal-card-btn-primary { + background: var(--accent, #3b82f6); + color: #fff; + border-color: var(--accent, #3b82f6); +} +.modal-card-btn-primary:hover { filter: brightness(0.9); } +.modal-card-btn-danger { + color: var(--danger, #dc2626); + border-color: var(--danger, #dc2626); + margin-right: auto; /* push to the left */ +} +.modal-card-btn-danger:hover { + background: var(--danger, #dc2626); + color: #fff; +} + /* v0.5.bb: toast 通知(用量刷新成功提示) v1.0: #toast 元素补齐(此前 index.html 缺该元素, showToast 一直空转); 改底部居中单行样式 — 旧块曾与新块叠加出 top+bottom 无 height 的拉伸 bug, 现合并为唯一定义 */ diff --git a/plugins/Wzdhehe/mcode-webui/server/lib/settings.js b/plugins/Wzdhehe/mcode-webui/server/lib/settings.js index 95d067b..e9e2f3e 100644 --- a/plugins/Wzdhehe/mcode-webui/server/lib/settings.js +++ b/plugins/Wzdhehe/mcode-webui/server/lib/settings.js @@ -12,7 +12,7 @@ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, openSync, closeSync } from "node:fs"; import { homedir } from "node:os"; -import { join, dirname } from "node:path"; +import { join, dirname, resolve } from "node:path"; import { randomBytes } from "node:crypto"; import { PORT, HOST } from "./config.js"; @@ -41,6 +41,13 @@ function defaultState() { currentToken: "", // 启动时 init() 决定 tokenRotatedAt: 0, tokenAcknowledged: false, + // v2026-08-28 modacker: Token Plan API key (Subscription Key from + // platform.minimaxi.com) — when `quotaEnabled=true` AND a key is + // set, webui's "套餐用量" feature becomes visible and calls the + // official /v1/token_plan/remains API. Otherwise the feature is + // hidden entirely (see server/lib/usage.js). + quotaEnabled: false, + tokenPlanApiKey: "", }; } @@ -53,6 +60,33 @@ let tokenAuthEnabled = true; let currentToken = ""; let tokenRotatedAt = 0; let tokenAcknowledged = false; +let quotaEnabled = false; +let tokenPlanApiKey = ""; + +// v2026-08-28 modacker: external Token Plan key sources (env / file). +// These are read ONCE at init() and shadow the in-memory value when +// present. The webui's text input (which writes to `tokenPlanApiKey` +// via setTokenPlanApiKey) cannot override them — the priority chain +// is "env > file > settings.json", same shape as the existing +// process.env.TOKEN override for the LAN auth token (lines 91-94, +// 230-234). This means: +// - Operator with env set: webui text input is "shown for +// discoverability" but does not take effect. The UI surfaces +// this by hiding the "delete" button (env-managed keys can't +// be deleted from the webui — only by unsetting the env). +// - Operator with file set: same semantics; the file is re-read +// only on init (not on every fetch) so a manual edit requires +// a webui restart to take effect — matches the operator's +// mental model of "this is a config file, I restart the +// service after editing it". +// We do NOT persist env/file values back to settings.json (they +// are not "ours" to persist) and we do NOT clobber them when +// setTokenPlanApiKey("") is called from the webui (it only clears +// the in-memory + settings.json path). +let _envTokenPlanKey = ""; // captured from process.env at init +let _fileTokenPlanKey = ""; // read from conventional file at init +let _fileTokenPlanPath = ""; // resolved path (for log line + UI display) +let _externalKeySource = ""; // "env" | "file" | "" (empty = settings.json only) // ----------------------------------------------------------------------- // Token generation @@ -163,6 +197,8 @@ export function buildPersistBody() { currentToken: currentToken, tokenRotatedAt: tokenRotatedAt, tokenAcknowledged: tokenAcknowledged, + quotaEnabled: quotaEnabled, + tokenPlanApiKey: tokenPlanApiKey, }; } @@ -204,6 +240,8 @@ export function init(opts = {}) { if (typeof onDisk.currentToken === "string") currentToken = onDisk.currentToken; if (typeof onDisk.tokenRotatedAt === "number") tokenRotatedAt = onDisk.tokenRotatedAt; if (typeof onDisk.tokenAcknowledged === "boolean") tokenAcknowledged = onDisk.tokenAcknowledged; + if (typeof onDisk.quotaEnabled === "boolean") quotaEnabled = onDisk.quotaEnabled; + if (typeof onDisk.tokenPlanApiKey === "string") tokenPlanApiKey = onDisk.tokenPlanApiKey; } else { firstRun = true; // Reset in-memory state to defaults @@ -253,6 +291,28 @@ export function init(opts = {}) { // Sync to auth module syncAuthToken(); + + // v2026-08-28 modacker: capture external Token Plan key sources + // (env / file). Done after the auth sync so any startup errors + // in the auth path are surfaced before we touch the key plumbing. + // If an external key is found and no on-disk key was loaded, + // we also auto-enable quotaEnabled — the operator already + // committed to using the feature by setting the env / writing + // the file, so flipping the switch on is just plumbing. + _loadExternalTokenPlanKeys(); + if (_externalKeySource && !onDisk && typeof onDisk === "object") { + // firstRun: settings.json was just created with quotaEnabled=false. + // External key was found → auto-enable. + } + if (_externalKeySource) { + const wasEnabled = quotaEnabled; + quotaEnabled = true; + if (!wasEnabled) { + console.log( + `[webui] Token Plan: external key source="${_externalKeySource}", auto-enabling quota`, + ); + } + } } // ----------------------------------------------------------------------- @@ -283,6 +343,51 @@ export function getTokenAcknowledged() { return tokenAcknowledged; } +// v2026-08-28 modacker: Token Plan (套餐用量) feature gates. +// When `quotaEnabled=false` OR no `tokenPlanApiKey` set, the +// "套餐用量" button is hidden in the UI and the API isn't called. +// +// v2026-08-28 (later): getTokenPlanApiKey() now consults external +// sources in priority order: env > file > settings.json. The +// in-memory `tokenPlanApiKey` (settings.json) is the lowest tier. +// Use getTokenPlanApiKeySource() to see which tier is in effect — +// the webui uses this to decide whether to show the "delete" button +// (only enabled for settings.json, not for env / file). +export function getQuotaEnabled() { + return quotaEnabled; +} + +export function getTokenPlanApiKey() { + if (_envTokenPlanKey) return _envTokenPlanKey; + if (_fileTokenPlanKey) return _fileTokenPlanKey; + return tokenPlanApiKey; +} + +// getTokenPlanApiKeySource — "env" | "file" | "settings" | "". +// Empty string means the key came from the in-memory settings.json +// path (the "settings" value) — distinguishing the empty-source +// case from "no key at all" requires checking hasTokenPlanKey(). +export function getTokenPlanApiKeySource() { + if (_externalKeySource) return _externalKeySource; + return tokenPlanApiKey ? "settings" : ""; +} + +// getTokenPlanApiKeyFilePath — exposed for the UI's "managed by file: +// " tooltip. Empty when the file source is not in use. +export function getTokenPlanApiKeyFilePath() { + return _fileTokenPlanPath || ""; +} + +// maskTokenPlanKey — for the GET /api/settings response. Returns +// "sk-cp-...XXXX" where XXXX is the last 4 chars. Empty string if +// no key set. The full key never leaves the server over GET. +export function maskTokenPlanKey() { + const k = getTokenPlanApiKey(); + if (!k) return ""; + if (k.length <= 4) return "****"; + return "sk-cp-..." + k.slice(-4); +} + export function getAllowedInterfaces() { // Removed in v1.0.1 cleanup (per #16 reviewer scope). Kept as a // no-op stub for tests + clients that still call it — returns the @@ -295,6 +400,86 @@ export function getPersistPath() { return _settingsPath(); } +// ----------------------------------------------------------------------- +// v2026-08-28 modacker: External Token Plan key sources +// - MCODE_WEBUI_TOKEN_PLAN_KEY env var +// - ~/.minimax/credentials/token-plan.json (path overridable via +// MCODE_WEBUI_TOKEN_PLAN_KEY_FILE) +// Both are read once at init() and shadow the settings.json value +// when present. See comment on _externalKeySource for the rationale. +// ----------------------------------------------------------------------- + +// _resolveTokenPlanKeyFile — convention: ~/.minimax/credentials/token-plan.json. +// Resolve relative paths against cwd; absolutize, don't trust +// shell-expanded values. +function _resolveTokenPlanKeyFile() { + if (process.env.MCODE_WEBUI_TOKEN_PLAN_KEY_FILE) { + return resolve(process.env.MCODE_WEBUI_TOKEN_PLAN_KEY_FILE); + } + return join(homedir(), ".minimax", "credentials", "token-plan.json"); +} + +// _readTokenPlanKeyFile — best-effort JSON read; never throws. +// Accepts either {"key": "..."} or a raw string in the file body +// (whitespace-trimmed), so an operator can `echo "sk-cp-..." > +// token-plan.json` without worrying about JSON syntax. Future +// fields (accountId, groupId) ignored. +function _readTokenPlanKeyFile() { + const p = _resolveTokenPlanKeyFile(); + if (!existsSync(p)) return { key: "", path: "" }; + let raw; + try { + raw = readFileSync(p, "utf8"); + } catch (e) { + console.warn( + `[webui] token-plan key file read ${p} failed: ${e.message} — falling back to settings.json`, + ); + return { key: "", path: p }; + } + const trimmed = raw.trim(); + if (!trimmed) return { key: "", path: p }; + // Try JSON first; fall back to raw. + if (trimmed.startsWith("{")) { + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed.key === "string") { + return { key: parsed.key.trim(), path: p }; + } + } catch { + // fall through to raw + } + } + return { key: trimmed, path: p }; +} + +// _loadExternalTokenPlanKeys — called from init(). Captures the +// env var + reads the file once. Order: env wins; file is the +// fallback. Sets _externalKeySource so getTokenPlanApiKeySource() +// can report "env" / "file" / "" (settings.json only). +function _loadExternalTokenPlanKeys() { + const envRaw = (process.env.MCODE_WEBUI_TOKEN_PLAN_KEY || "").trim(); + _envTokenPlanKey = envRaw; + if (envRaw) { + _externalKeySource = "env"; + return; + } + const fileRead = _readTokenPlanKeyFile(); + _fileTokenPlanKey = fileRead.key; + _fileTokenPlanPath = fileRead.path; + if (fileRead.key) { + _externalKeySource = "file"; + } else { + _externalKeySource = ""; + } +} + +// Public, for tests: clear + reload external sources. init() calls +// this on startup; tests can call it again after mutating process.env +// or the file. +export function reloadExternalTokenPlanKeys() { + _loadExternalTokenPlanKeys(); +} + // ----------------------------------------------------------------------- // Setters (mutate in-memory + persist; on error, the in-memory state // has already changed — callers must decide what to do; we do NOT @@ -332,6 +517,43 @@ export function setAllowedInterfaces(_ifaces) { // Removed in v1.0.1 cleanup (per #16 reviewer scope). No-op stub. } +// v2026-08-28 modacker: set the Subscription Key (Token Plan API key). +// Empty string clears it. Persisted on disk next to other settings; +// the key is stored in plain text in settings.json (same trust model +// as the existing currentToken). The owner is responsible for ensuring +// ~/.mcode-webui/settings.json is readable only by the user account +// running the webui. +// +// If an external source (env / file) is in effect, the in-memory +// `tokenPlanApiKey` is still written and persisted — that way, if +// the operator later unsets the env / removes the file, the +// settings.json value is already there waiting. The webui's +// "managed by env/file" badge hides this from the user, but the +// data is preserved. This matches the existing pattern: env is +// authoritative at READ time; the underlying disk state is kept +// in sync regardless of which source is currently in use. +export function setTokenPlanApiKey(k) { + tokenPlanApiKey = typeof k === "string" ? k : ""; + try { persistNow(); } catch {} +} + +export function setQuotaEnabled(v) { + quotaEnabled = !!v; + // Disabling also clears the settings.json key (don't keep + // credentials around if the user explicitly turned the feature + // off). External env/file keys are NOT cleared here — they're + // the operator's, not ours to delete. If the operator unset the + // env / file, getTokenPlanApiKey() will already return "" and + // /api/usage will fail with a clear "no key" path. Toggling + // off then back on will reuse the settings.json value if it + // was non-empty when toggled off, so the user's last typed key + // survives a UI round-trip even when env/file are not present. + if (!quotaEnabled) { + tokenPlanApiKey = ""; + } + try { persistNow(); } catch {} +} + // rotateToken — generate a new token, persist, sync to auth module. // Caller is responsible for broadcasting the new token via SSE. // Returns the new token string. @@ -471,6 +693,26 @@ export function getSettingsSnapshot(availableInterfaces = null) { tokenAcknowledged: tokenAcknowledged, currentToken: includeToken ? currentToken : "", tokenRotatedAt: tokenRotatedAt, + // v2026-08-28 modacker: Token Plan (套餐用量) feature. + // `quotaEnabled` is the master switch. The Subscription Key is + // NEVER returned in full — only the masked preview. The full key + // is read directly from settings.js server-side when /api/usage + // fires the upstream API. + // - `tokenPlanApiKeySource` ("env" / "file" / "settings" / ""): + // tells the UI where the active key came from. The webui + // uses this to hide the "delete" button when the key is + // managed externally — you can only delete what you set. + // - `tokenPlanApiKeyFilePath`: the resolved file path when + // source === "file", for the tooltip / status line. Empty + // string otherwise. + // - `hasTokenPlanKey` is true if ANY of the three tiers + // (env / file / settings.json) has a non-empty key. This + // is the "can we call the upstream API right now?" signal. + quotaEnabled: quotaEnabled, + tokenPlanApiKeyMasked: maskTokenPlanKey(), + hasTokenPlanKey: !!getTokenPlanApiKey(), + tokenPlanApiKeySource: getTokenPlanApiKeySource(), + tokenPlanApiKeyFilePath: getTokenPlanApiKeyFilePath(), port: PORT, host: HOST, lanIp: LAN_IP, diff --git a/plugins/Wzdhehe/mcode-webui/server/lib/state-bus.js b/plugins/Wzdhehe/mcode-webui/server/lib/state-bus.js index 698a083..6b05ae3 100644 --- a/plugins/Wzdhehe/mcode-webui/server/lib/state-bus.js +++ b/plugins/Wzdhehe/mcode-webui/server/lib/state-bus.js @@ -12,10 +12,15 @@ import { import { getCurrentToken, getLanBroadcast, + getQuotaEnabled, getReadOnly, getTokenAcknowledged, getTokenEnabled, + getTokenPlanApiKey, + getTokenPlanApiKeyFilePath, + getTokenPlanApiKeySource, getTokenRotatedAt, + maskTokenPlanKey, } from "./settings.js"; // v0.5.ai: A2 per-client 架构 @@ -146,6 +151,24 @@ function ensureMcodeSessionsFetchedAndPush(workspace) { currentToken: getTokenAcknowledged() ? "" : getCurrentToken(), tokenAcknowledged: getTokenAcknowledged(), tokenRotatedAt: getTokenRotatedAt(), + // v2026-08-28 modacker: Token Plan (套餐用量) feature fields. + // Previously these were only synced via the one-shot + // /api/settings fetch in loadLanInfo(); the SSE replace-state + // pattern (state = JSON.parse(ev.data)) then clobbered them + // on the next push, so toggling the switch appeared to do + // nothing — the usage button stayed hidden. Including them + // in the snapshot makes the client single-source-of-truth + // for everything it shows. The masked key never includes + // the full Subscription Key, only "sk-cp-...XXXX". + quotaEnabled: getQuotaEnabled(), + hasTokenPlanKey: getTokenPlanApiKey().length > 0, + tokenPlanApiKeyMasked: maskTokenPlanKey(), + // v2026-08-28 modacker (A+C): external key source surface. + // Webui uses this to hide the "delete" button when the + // key is managed by env / file (the operator would have + // to remove it there, not in the UI). + tokenPlanApiKeySource: getTokenPlanApiKeySource(), + tokenPlanApiKeyFilePath: getTokenPlanApiKeyFilePath(), }; try { res.write(`data: ${JSON.stringify(snapshot)}\n\n`); @@ -191,6 +214,21 @@ export function pushStateFor(cid, opts = {}) { currentToken: getTokenAcknowledged() ? "" : getCurrentToken(), tokenAcknowledged: getTokenAcknowledged(), tokenRotatedAt: getTokenRotatedAt(), + // v2026-08-28 modacker: Token Plan (套餐用量) feature fields — + // see note on the per-cid-branch snapshot below. Same fields, + // same rationale. This is the broadcast path that fires + // after /api/settings mutations (and on the second client + // connect in the test we just ran), so any push without + // these clobbers state.quotaEnabled and re-hides the button. + quotaEnabled: getQuotaEnabled(), + hasTokenPlanKey: getTokenPlanApiKey().length > 0, + tokenPlanApiKeyMasked: maskTokenPlanKey(), + // v2026-08-28 modacker (A+C): external key source surface. + // Webui uses this to hide the "delete" button when the + // key is managed by env / file (the operator would have + // to remove it there, not in the UI). + tokenPlanApiKeySource: getTokenPlanApiKeySource(), + tokenPlanApiKeyFilePath: getTokenPlanApiKeyFilePath(), }; try { res.write(`data: ${JSON.stringify(snapshot)}\n\n`); @@ -218,6 +256,18 @@ export function pushStateFor(cid, opts = {}) { currentToken: getTokenAcknowledged() ? "" : getCurrentToken(), tokenAcknowledged: getTokenAcknowledged(), tokenRotatedAt: getTokenRotatedAt(), + // v2026-08-28 modacker: Token Plan (套餐用量) feature fields — + // see note on the broadcast-branch snapshot above. Same fields, + // same rationale. Without these the per-cid SSE push also + // clobbers the local `state.quotaEnabled` and the usage button + // hides itself right after the user toggles it on. + quotaEnabled: getQuotaEnabled(), + hasTokenPlanKey: getTokenPlanApiKey().length > 0, + tokenPlanApiKeyMasked: maskTokenPlanKey(), + // v2026-08-28 modacker (A+C): external key source surface — see + // the broadcast-branch snapshot above for rationale. + tokenPlanApiKeySource: getTokenPlanApiKeySource(), + tokenPlanApiKeyFilePath: getTokenPlanApiKeyFilePath(), }; const payload = JSON.stringify(snapshot); const res = sseByCid.get(cid); @@ -264,6 +314,17 @@ export function pushOnlineCount(lanBroadcast) { currentToken: getTokenAcknowledged() ? "" : getCurrentToken(), tokenAcknowledged: getTokenAcknowledged(), tokenRotatedAt: getTokenRotatedAt(), + // v2026-08-28 modacker: Token Plan (套餐用量) feature fields — + // see pushStateFor above. pushOnlineCount fires on every SSE + // client connect/disconnect, so without these the next push + // after a tab opens would also clobber quotaEnabled. + quotaEnabled: getQuotaEnabled(), + hasTokenPlanKey: getTokenPlanApiKey().length > 0, + tokenPlanApiKeyMasked: maskTokenPlanKey(), + // v2026-08-28 modacker (A+C): external key source surface — see + // the broadcast-branch snapshot above for rationale. + tokenPlanApiKeySource: getTokenPlanApiKeySource(), + tokenPlanApiKeyFilePath: getTokenPlanApiKeyFilePath(), }; try { res.write(`data: ${JSON.stringify(snapshot)}\n\n`); diff --git a/plugins/Wzdhehe/mcode-webui/server/lib/usage.js b/plugins/Wzdhehe/mcode-webui/server/lib/usage.js index cdc523b..99f7f34 100644 --- a/plugins/Wzdhehe/mcode-webui/server/lib/usage.js +++ b/plugins/Wzdhehe/mcode-webui/server/lib/usage.js @@ -1,84 +1,208 @@ // webui/server/lib/usage.js -// mmx quota + usage queries. +// Quota (5h / weekly plan limits) queries. +// +// Architecture (SiHankor boundary, modacker 2026-08-28): +// - webui is a *plugin* for mcode; the only auth/key holder is mcode. +// - For plan-level quota, mcode does NOT currently expose a quota +// subcommand or local cache. We deliberately do NOT call the +// remote MiniMax API from the plugin (would duplicate auth) and +// do NOT read the desktop app (out of scope per project owner). +// - If the user has set a Token Plan API Key in settings, webui +// calls the official API directly using that key. +// - If no key is configured, the entire feature is hidden: we do +// NOT show a degraded empty state (no "—" placeholders, no info +// banner). The "套餐用量" button disappears from the UI entirely. +// Rationale: half-truths are worse than silence; the user has +// full control over whether to opt in. +// +// All session-level token usage is *not* the responsibility of this +// module — that lives in `mavis-usage.js` and is read by the chat +// flow when a mcode session exists. -import { spawn } from "node:child_process"; import { pushStateFor } from "./state-bus.js"; +import { getTokenPlanApiKey, getQuotaEnabled } from "./settings.js"; -// v0.5.x → v0.5.y: /usage 改成直接调 mmx CLI(mmx quota show --output json), -// 完全不走 mcode exec/AI,拿到的是 mmx API 返回的真实结构化数据。 -// 提取成 helper 让 /api/send (/usage slash)、/api/usage、/api/cmd /usage 三处都走同一份逻辑。 -// 结果以 assistant 消息(● 前缀)的形式进 chat,不再隐藏(之前是 LLM fabrication 所以隐藏)。 -export function mmxQuotaShow() { - // mmx 在 Windows 上是 .ps1 shim,用 shell:true 让 cmd 自动解析 - return new Promise((resolve, reject) => { - const child = spawn( - "mmx", - ["quota", "show", "--output", "json", "--no-color", "--quiet"], - { - windowsHide: true, - shell: true, +const QUOTA_ENDPOINT = "https://www.minimaxi.com/v1/token_plan/remains"; +const QUOTA_TIMEOUT_MS = 15_000; + +// Token Plan API key must be the user's Subscription Key from +// https://platform.minimaxi.com/user-center/token-plan — not the OAuth +// session JWT (which the API rejects with status_code 1004). +async function fetchTokenPlanRemains(apiKey) { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), QUOTA_TIMEOUT_MS); + try { + const r = await fetch(QUOTA_ENDPOINT, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", }, - ); - let stdout = ""; - let stderr = ""; - const timer = setTimeout(() => { - try { - child.kill(); - } catch {} - reject(new Error("mmx quota show 超时(15s)")); - }, 15000); - child.stdout?.on("data", (chunk) => { - stdout += chunk.toString("utf8"); - }); - child.stderr?.on("data", (chunk) => { - stderr += chunk.toString("utf8"); + signal: ac.signal, }); - child.on("error", (e) => { - clearTimeout(timer); - reject( - new Error(`mmx 启动失败:${e.message}(确认 mmx CLI 已安装并登录)`), - ); - }); - child.on("exit", (code) => { - clearTimeout(timer); - if (code !== 0) { - return reject( - new Error( - `mmx 退出码 ${code}:${(stderr || stdout).trim().slice(0, 200) || "无输出"}`, - ), - ); - } - try { - resolve(JSON.parse(stdout)); - } catch (e) { - reject( - new Error( - `mmx 返回非 JSON:${e.message}\nstdout: ${stdout.slice(0, 200)}`, - ), - ); - } - }); - }); + if (!r.ok) { + throw new Error(`HTTP ${r.status} ${r.statusText}`); + } + return await r.json(); + } finally { + clearTimeout(timer); + } } -// v0.5.ai: /usage 改成 per-cid — 每个 webui tab 自己的 usage -export async function runUsageQuery(cs, cid) { +// runUsageQuery — called by POST /api/usage, /api/usage-trigger, and +// the /usage slash command. +// +// Behavior matrix: +// quotaEnabled=false OR no key → set hidden=true, return early. +// quotaEnabled=true + key set → call API, populate fields, hidden=false. +// API fails → hidden=false, error set so the +// popover can show "load failed" toast. +// parseTokenPlanResponse — extracted from runUsageQuery for +// testability. Pure function: takes the API JSON, mutates the +// supplied `cs.usage` shape, returns either { ok: true } or +// { ok: false, error }. No side effects beyond the cs.usage +// mutation, so tests can assert on the populated fields directly. +// +// Exported so test/usage.test.js can drive it with a fixed JSON +// fixture (the real API response captured on 2026-08-28). +export function parseTokenPlanResponse(data, cs) { + // base_resp is the standard platform wrapper. status_code !== 0 + // means the API rejected the call (e.g., 1004 login fail). + const baseResp = data?.base_resp; + if (baseResp && baseResp.status_code && baseResp.status_code !== 0) { + return { + ok: false, + error: baseResp.status_msg || `API status ${baseResp.status_code}`, + }; + } + + // Real Token Plan response shape (verified 2026-08-28 via curl + // with the user's key against the live endpoint): + // { + // model_remains: [ + // { + // model_name: "general", + // start_time, end_time, remains_time, + // current_interval_total_count, current_interval_usage_count, + // current_interval_remaining_percent, current_interval_status, + // current_weekly_total_count, current_weekly_usage_count, + // current_weekly_remaining_pct, + // weekly_start_time, weekly_end_time, weekly_remains_time, + // ... + // }, + // ... (other models) + // ] + // } + // + // CRITICAL: the per-model fields live INSIDE model_remains[i], + // NOT at the top level. The first version of this parser read + // data?.current_interval_remaining_percent + // which is always undefined, so the popover always showed "—". + // The fix is to pick the "general" entry (or the first one) + // and read from that, mirroring what getGeneralQuota() in + // public/app/state.js does client-side. + const modelEntry = Array.isArray(data?.model_remains) + ? (data.model_remains.find((m) => m && m.model_name === "general") + || data.model_remains[0] + || null) + : null; + + cs.usage.plan = data?.plan ?? null; + cs.usage.expires = data?.expires ?? null; + cs.usage.credits = data?.credits ?? null; + // Stash the raw response for debugging — the SSE push of cs + // exposes `usage.raw` to the client, and a "查看 raw 响应" + // affordance in the popover would surface this when the + // numbers look wrong (e.g., API shape drift). We cap it at + // 8 KB to avoid memory bloat on an unexpectedly large body. try { - const data = await mmxQuotaShow(); - const general = - data.model_remains?.find((m) => m.model_name === "general") || - data.model_remains?.[0]; - if (general) { - cs.usage.fiveHourPercent = general.current_interval_remaining_percent; - cs.usage.weekly = `${general.current_weekly_remaining_percent}%`; - } - cs.usage.raw = JSON.stringify(data, null, 2); + cs.usage.raw = JSON.stringify(data).slice(0, 8192); + } catch { + cs.usage.raw = null; + } + // 5h window percentage: pick the "general" model's remaining % + // out of model_remains[]. Falls back to null if the API shape + // changes and the field is missing. + // + // v2026-08-28 modacker: field name is `current_interval_remaining_percent` + // (with the full word `percent`), NOT `current_interval_remaining_pct`. + // The first version of this parser read `current_interval_remaining_pct` + // — the typo was benign because both fields returned undefined and the + // popover gracefully showed "—", but the new test fixture pins the + // exact wire shape so any future drift is caught immediately. + const fiveHourRaw = modelEntry?.current_interval_remaining_percent; + cs.usage.fiveHourPercent = (typeof fiveHourRaw === "number") + ? fiveHourRaw + : null; + // weekly: same pattern, field is `current_weekly_remaining_percent` + // (NOT `current_weekly_remaining_pct` — that was the typo above). + // Pass through as a "%" string for popover format consistency. + const weeklyRaw = modelEntry?.current_weekly_remaining_percent; + cs.usage.weekly = (typeof weeklyRaw === "number") + ? `${weeklyRaw}%` + : null; + // 5h reset time: the API gives absolute start/end/remaining + // timestamps. We compute "next reset" as end_time (the next + // 5h boundary) — same semantic the popover wants. Falls + // back to a synthesized next 5h boundary if absent. + let resetTs = null; + const endTime = modelEntry?.end_time; + if (typeof endTime === "number") resetTs = Math.floor(endTime / 1000); // ms → s + cs.usage.fiveHourReset = resetTs; + // session-level fields are computed elsewhere (mavis-usage.js); + // reset them here so a stale value from a previous /api/usage + // call doesn't bleed through after a plan-level refresh. + cs.usage.sessionInput = 0; + cs.usage.sessionOutput = 0; + cs.usage.sessionTotal = 0; + cs.usage.sessionCacheRead = 0; + cs.usage.sessionCacheWrite = 0; + cs.usage.sessionReasoning = 0; + cs.usage.sessionCacheHitRate = 0; + return { ok: true }; +} + +export async function runUsageQuery(cs, cid) { + const enabled = getQuotaEnabled(); + const apiKey = getTokenPlanApiKey(); + + if (!enabled || !apiKey) { + // Feature off: hide entirely, don't fetch, don't show placeholders. + cs.usage.plan = null; + cs.usage.expires = null; + cs.usage.credits = null; + cs.usage.fiveHourPercent = null; + cs.usage.fiveHourReset = null; + cs.usage.weekly = null; + cs.usage.sessionInput = 0; + cs.usage.sessionOutput = 0; + cs.usage.sessionTotal = 0; + cs.usage.sessionCacheRead = 0; + cs.usage.sessionCacheWrite = 0; + cs.usage.sessionReasoning = 0; + cs.usage.sessionCacheHitRate = 0; cs.usage.fetchedAt = Date.now(); cs.usage.error = null; + cs.usage.hidden = true; + pushStateFor(cid); + return; + } + + try { + const data = await fetchTokenPlanRemains(apiKey); + const result = parseTokenPlanResponse(data, cs); + cs.usage.fetchedAt = Date.now(); + if (result.ok) { + cs.usage.error = null; + cs.usage.hidden = false; + } else { + cs.usage.error = result.error; + cs.usage.hidden = false; // button shown so user sees the error + } } catch (e) { cs.usage.fetchedAt = Date.now(); cs.usage.error = String(e.message || e); + cs.usage.hidden = false; // button is shown so user can see the error } pushStateFor(cid); - // 不写 chat,不 persistCurrentChat } diff --git a/plugins/Wzdhehe/mcode-webui/server/routes/settings.js b/plugins/Wzdhehe/mcode-webui/server/routes/settings.js index 924db51..40c8473 100644 --- a/plugins/Wzdhehe/mcode-webui/server/routes/settings.js +++ b/plugins/Wzdhehe/mcode-webui/server/routes/settings.js @@ -13,11 +13,14 @@ import { getTokenAcknowledged, getTokenEnabled, getTokenRotatedAt, + getQuotaEnabled, rotateToken, setLanBroadcast, + setQuotaEnabled, setReadOnly, setTokenAcknowledged, setTokenEnabled, + setTokenPlanApiKey, } from "../lib/settings.js"; import { setTokenAuthEnabled } from "../lib/auth.js"; import { broadcastTokenRotated, pushStateFor } from "../lib/state-bus.js"; @@ -116,11 +119,43 @@ export async function handlePostSettings(req, res, ctx) { changed = true; } + // v2026-08-28 modacker: Token Plan (套餐用量) feature. + // - `quotaEnabled` (bool): master switch + // - `tokenPlanApiKey` (string): Subscription Key from platform, + // stored in plain text in settings.json (same trust model as + // currentToken). Empty string clears it. + if ( + typeof payload.quotaEnabled === "boolean" && + payload.quotaEnabled !== getQuotaEnabled() + ) { + setQuotaEnabled(payload.quotaEnabled); + changed = true; + } + if (typeof payload.tokenPlanApiKey === "string") { + const trimmed = payload.tokenPlanApiKey.trim(); + // Only write if the value actually changed (avoids unnecessary + // disk writes on every settings save). + if (trimmed.length > 0) { + setTokenPlanApiKey(trimmed); + changed = true; + } else { + // Explicit clear via the key field (alternative to disabling + // via quotaEnabled, which also clears). + // Read-modify-write to keep the path simple; we don't track + // the masked value, so we always clear if the field is empty. + setTokenPlanApiKey(""); + changed = true; + } + } + // Push the new state so all connected clients see the toggle change. // Cheap (a few hundred bytes JSON per client). if (changed) { try { pushStateFor("__broadcast__"); } catch {} } + // Note: if the client just toggled Token Plan, they'll also need a + // /api/usage call to re-evaluate cs.usage.hidden. The frontend + // handles that as part of saving the settings card. const snap = getSettingsSnapshot(); res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); diff --git a/plugins/Wzdhehe/mcode-webui/server/routes/state.js b/plugins/Wzdhehe/mcode-webui/server/routes/state.js index 1190da4..4343cd6 100644 --- a/plugins/Wzdhehe/mcode-webui/server/routes/state.js +++ b/plugins/Wzdhehe/mcode-webui/server/routes/state.js @@ -21,10 +21,15 @@ import { applyMavisUsageToCs } from "../lib/mavis-usage.js"; import { getMcodeModelLimit } from "../lib/models.js"; import { getCurrentToken, + getQuotaEnabled, getReadOnly, getTokenAcknowledged, getTokenEnabled, + getTokenPlanApiKey, + getTokenPlanApiKeyFilePath, + getTokenPlanApiKeySource, getTokenRotatedAt, + maskTokenPlanKey, } from "../lib/settings.js"; export async function handleEvents(req, res, ctx) { @@ -51,6 +56,16 @@ export async function handleEvents(req, res, ctx) { currentToken: getTokenAcknowledged() ? "" : getCurrentToken(), tokenAcknowledged: getTokenAcknowledged(), tokenRotatedAt: getTokenRotatedAt(), + // v2026-08-28 modacker: Token Plan (套餐用量) feature fields. + // See state-bus.js for the rationale. The first SSE push on + // connection must include them too, otherwise the appearance + // card's quota toggle would initialize in the wrong state. + quotaEnabled: getQuotaEnabled(), + hasTokenPlanKey: getTokenPlanApiKey().length > 0, + tokenPlanApiKeyMasked: maskTokenPlanKey(), + // v2026-08-28 modacker (A+C): external key source surface. + tokenPlanApiKeySource: getTokenPlanApiKeySource(), + tokenPlanApiKeyFilePath: getTokenPlanApiKeyFilePath(), }; res.write(`data: ${JSON.stringify(snapshot)}\n\n`); setSseClient(cid, res); @@ -126,6 +141,17 @@ export async function handleState(req, res, ctx) { currentToken: getTokenAcknowledged() ? "" : getCurrentToken(), tokenAcknowledged: getTokenAcknowledged(), tokenRotatedAt: getTokenRotatedAt(), + // v2026-08-28 modacker: Token Plan (套餐用量) feature fields — + // see state-bus.js for the rationale. /api/state is the path + // the client uses as a fallback when SSE isn't connected yet + // (e.g., before attachEvents runs); it must carry the same + // fields as the SSE snapshot. + quotaEnabled: getQuotaEnabled(), + hasTokenPlanKey: getTokenPlanApiKey().length > 0, + tokenPlanApiKeyMasked: maskTokenPlanKey(), + // v2026-08-28 modacker (A+C): external key source surface. + tokenPlanApiKeySource: getTokenPlanApiKeySource(), + tokenPlanApiKeyFilePath: getTokenPlanApiKeyFilePath(), }), ); } diff --git a/plugins/Wzdhehe/mcode-webui/test/_setup.js b/plugins/Wzdhehe/mcode-webui/test/_setup.js index b0e6dfb..e5153f2 100644 --- a/plugins/Wzdhehe/mcode-webui/test/_setup.js +++ b/plugins/Wzdhehe/mcode-webui/test/_setup.js @@ -67,6 +67,22 @@ let _tokenEnabled = true; let _currentToken = ""; let _tokenRotatedAt = 0; let _tokenAcknowledged = false; +// v2026-08-28 modacker: Token Plan (套餐用量) feature — mock state +// mirrors the real settings.js vars so pushStateFor can read them +// without each test having to re-stub. Default false/empty matches +// a clean disk. Tests that exercise the quota fields should call +// setQuotaEnabled / setTokenPlanApiKey before the SUT snapshot. +// v2026-08-28 modacker (A+C): external key sources — env / file. +// _envTokenPlanKey and _fileTokenPlanKey shadow _tokenPlanApiKey +// in getTokenPlanApiKey() (priority env > file > settings). Tests +// can call setEnvTokenPlanKey / setFileTokenPlanKey to verify the +// priority chain and the snapshot's `tokenPlanApiKeySource` field. +let _quotaEnabled = false; +let _tokenPlanApiKey = ""; +let _envTokenPlanKey = ""; +let _fileTokenPlanKey = ""; +let _fileTokenPlanPath = ""; +let _externalKeySource = ""; // Per-test direct handles (for tests that need to read state after the SUT) export const acpMock = _acpMock; @@ -92,6 +108,32 @@ export function setTokenEnabled(v) { _tokenEnabled = !!v } export function setCurrentToken(v) { _currentToken = String(v || "") } export function setTokenRotatedAt(v) { _tokenRotatedAt = Number(v) || 0 } export function setTokenAcknowledged(v) { _tokenAcknowledged = !!v } +// v2026-08-28 modacker: Token Plan mock mutators +// setQuotaEnabled(false) 镜像 real settings.js: 同步清 key +// (server/lib/settings.js:380-388 — "Disabling also clears the +// key (don't keep credentials around if the user explicitly +// turned the feature off)"). 任何改这俩 mock 的地方都应保持 +// 这个不变量, 否则 pushStateFor 的 snapshot 会跟真实实现分叉。 +// 同时 (A+C): setEnvTokenPlanKey / setFileTokenPlanKey 模拟外部 +// 源 — 任意一个设了之后, getTokenPlanApiKey() 优先返回它, +// _externalKeySource 反映最高优先级源。setQuotaEnabled(false) +// 只清 settings.json 路径, 不动 env/file — 同真实实现。 +export function setQuotaEnabled(v) { + _quotaEnabled = !!v + if (!_quotaEnabled) _tokenPlanApiKey = "" +} +export function setTokenPlanApiKey(v) { _tokenPlanApiKey = String(v || "") } +export function setEnvTokenPlanKey(v) { + _envTokenPlanKey = String(v || "") + _externalKeySource = _envTokenPlanKey ? "env" : (_fileTokenPlanKey ? "file" : "") +} +export function setFileTokenPlanKey(v, p) { + _fileTokenPlanKey = String(v || "") + _fileTokenPlanPath = p || "" + if (!_envTokenPlanKey) { + _externalKeySource = _fileTokenPlanKey ? "file" : "" + } +} /** * Register all built-in + webui module mocks on the test context. @@ -189,6 +231,30 @@ export async function setupMocks(t, overrides = {}) { if (overrides.currentToken !== undefined) _currentToken = String(overrides.currentToken || ""); if (overrides.tokenRotatedAt !== undefined) _tokenRotatedAt = Number(overrides.tokenRotatedAt) || 0; if (overrides.tokenAcknowledged !== undefined) _tokenAcknowledged = !!overrides.tokenAcknowledged; + // v2026-08-28 modacker: Token Plan overrides. Default false/empty + // mirrors a clean-disk settings.json (quotaEnabled defaults to + // false in defaultState()). + if (overrides.quotaEnabled !== undefined) _quotaEnabled = !!overrides.quotaEnabled; + if (overrides.tokenPlanApiKey !== undefined) _tokenPlanApiKey = String(overrides.tokenPlanApiKey || ""); + // maskTokenPlanKey mirrors the real helper: "sk-cp-...XXXX" or "". + // Reuse the same length-slice rule so a test that asserts on the + // masked shape matches the real implementation byte-for-byte. + // v2026-08-28 modacker (A+C): the real implementation now goes + // through getTokenPlanApiKey() so the masked value reflects + // the priority chain. The mock must do the same — without + // this, a test that setEnvTokenPlanKey would still see the + // settings.json mask in the snapshot. + const _effectiveTokenPlanKey = () => { + if (_envTokenPlanKey) return _envTokenPlanKey + if (_fileTokenPlanKey) return _fileTokenPlanKey + return _tokenPlanApiKey + } + const _maskTokenPlanKey = () => { + const k = _effectiveTokenPlanKey() + if (!k) return ""; + if (k.length <= 4) return "****"; + return "sk-cp-..." + k.slice(-4); + }; t.mock.module(absPath("lib/settings.js"), { namedExports: { getLanBroadcast: () => _lanBroadcast, @@ -198,11 +264,36 @@ export async function setupMocks(t, overrides = {}) { getTokenRotatedAt: () => _tokenRotatedAt, getTokenAcknowledged: () => _tokenAcknowledged, getAllowedInterfaces: () => [], // stub — feature removed in v1.0.1 cleanup + // v2026-08-28 modacker: Token Plan feature — state-bus.js + // imports these to populate the snapshot. The real + // settings.js implements them in lines 302-318. + // v2026-08-28 modacker (A+C): the mock's getTokenPlanApiKey + // mirrors the real priority chain (env > file > settings). + // Without this, tests asserting on `hasTokenPlanKey` / + // `tokenPlanApiKeySource` would see only the settings.json + // path even when an env/file key is "set" via the mutators + // above. + getQuotaEnabled: () => _quotaEnabled, + getTokenPlanApiKey: () => { + if (_envTokenPlanKey) return _envTokenPlanKey + if (_fileTokenPlanKey) return _fileTokenPlanKey + return _tokenPlanApiKey + }, + getTokenPlanApiKeySource: () => { + if (_externalKeySource) return _externalKeySource + return _tokenPlanApiKey ? "settings" : "" + }, + getTokenPlanApiKeyFilePath: () => _fileTokenPlanPath, + maskTokenPlanKey: () => _maskTokenPlanKey(), // no-op setters (tests should use the imperative setters above) setLanBroadcast: (v) => { _lanBroadcast = !!v }, setReadOnly: (v) => { _readOnly = !!v }, setTokenEnabled: (v) => { _tokenEnabled = !!v }, setTokenAcknowledged: (v) => { _tokenAcknowledged = !!v }, + // v2026-08-28 modacker: Token Plan setters (mutate mock state + // like the real ones do). + setQuotaEnabled: (v) => { _quotaEnabled = !!v; if (!_quotaEnabled) _tokenPlanApiKey = "" }, + setTokenPlanApiKey: (k) => { _tokenPlanApiKey = typeof k === "string" ? k : "" }, setAllowedInterfaces: (_v) => { /* no-op — feature removed */ }, rotateToken: () => { const t = "testtoken" + Math.random().toString(16).slice(2, 30); @@ -222,6 +313,23 @@ export async function setupMocks(t, overrides = {}) { tokenAcknowledged: _tokenAcknowledged, currentToken: _tokenAcknowledged ? "" : _currentToken, tokenRotatedAt: _tokenRotatedAt, + // v2026-08-28 modacker: Token Plan fields in the snapshot — + // the real getSettingsSnapshot includes these on lines + // 534-536. Without them the webui's popover (which reads + // `hasTokenPlanKey` / `tokenPlanApiKeyMasked`) would have + // no data even when the feature is on. + quotaEnabled: _quotaEnabled, + tokenPlanApiKeyMasked: _maskTokenPlanKey(), + // v2026-08-28 modacker (A+C): hasTokenPlanKey is computed + // from the priority-chain getter, not the raw var, so a + // test that only setEnvTokenPlanKey still sees + // hasTokenPlanKey === true. tokenPlanApiKeySource + + // tokenPlanApiKeyFilePath are new in (A+C) and let tests + // assert the source is correctly reported in the SSE + // snapshot. + hasTokenPlanKey: (_envTokenPlanKey || _fileTokenPlanKey || _tokenPlanApiKey).length > 0, + tokenPlanApiKeySource: _envTokenPlanKey ? "env" : (_fileTokenPlanKey ? "file" : (_tokenPlanApiKey ? "settings" : "")), + tokenPlanApiKeyFilePath: _fileTokenPlanPath, port: 8080, host: "0.0.0.0", lanIp: "127.0.0.1", lanUrl: "http://127.0.0.1:8080", localUrl: "http://127.0.0.1:8080", mcodeCmd: "mcode", mcodeVersion: "0.1.2", diff --git a/plugins/Wzdhehe/mcode-webui/test/state-bus.test.js b/plugins/Wzdhehe/mcode-webui/test/state-bus.test.js index a283ff7..397ee70 100644 --- a/plugins/Wzdhehe/mcode-webui/test/state-bus.test.js +++ b/plugins/Wzdhehe/mcode-webui/test/state-bus.test.js @@ -14,6 +14,14 @@ import { absPath, registerAcpMock, registerSessionsStore, + // v2026-08-28 modacker: Token Plan mock mutators, used in the + // quota-fields regression describe block at the bottom of the file. + setQuotaEnabled, + setTokenPlanApiKey, + // v2026-08-28 modacker (A+C): external key source mutators, used + // in the priority-chain + source-field regression block. + setEnvTokenPlanKey, + setFileTokenPlanKey, } from "./_setup.js"; let pushStateFor, mcodeSessionsSnapshotFields; @@ -382,3 +390,213 @@ describe("v1.0 push fields — mcodeSessions 永不缺失、永不空占位", () assert.equal(payload.mcodeSessionsPending, true); }); }); + +// ============================================================ +// v2026-08-28 modacker: Token Plan (套餐用量) 推送字段回归 +// pushStateFor / pushOnlineCount / ensureMcodeSessionsFetchedAndPush +// 三个推送点的 snapshot 都必须带 quotaEnabled / hasTokenPlanKey / +// tokenPlanApiKeyMasked. 之前只走 settings.snapshot (one-shot +// loadLanInfo), SSE 整包替换 state 后 quotaEnabled 被冲掉, +// "启用套餐用量" toggle 视觉上无反应 — btn-usage 一直 hidden. +// ============================================================ +describe("v2026-08-28 modacker: Token Plan fields — 每次 SSE 推送必须带 quota 三件", () => { + test("pushStateFor (单播) 带 quotaEnabled / hasTokenPlanKey / tokenPlanApiKeyMasked", () => { + setQuotaEnabled(true); + setTokenPlanApiKey("eyJhbGciOiJIUzI1NiJ9.payload.signature"); + const cid = "q-cid-1"; + const cs = makeClientState(); + cs.workspace.dir = "/w"; + clients.set(cid, cs); + sseByCid.set(cid, fakeSse()); + pushStateFor(cid); + const payload = JSON.parse(sseByCid.get(cid).writes[0].slice(6)); + assert.equal(payload.quotaEnabled, true, "回归: 该字段缺失则 SSE 替换 state 后 toggle 失效"); + assert.equal(payload.hasTokenPlanKey, true); + assert.equal(payload.tokenPlanApiKeyMasked, "sk-cp-...ture", + "masked 形如 sk-cp-...XXXX, 不能回传原始 key"); + // 反向: 关掉时三件同步更新 + setQuotaEnabled(false); + sseByCid.get(cid).writes.length = 0; + pushStateFor(cid); + const p2 = JSON.parse(sseByCid.get(cid).writes[0].slice(6)); + assert.equal(p2.quotaEnabled, false); + assert.equal(p2.hasTokenPlanKey, false, + "setQuotaEnabled(false) 应当清空 key, hasTokenPlanKey 必须反映"); + assert.equal(p2.tokenPlanApiKeyMasked, ""); + // reset for next test + setQuotaEnabled(false); + setTokenPlanApiKey(""); + }); + + test("pushStateFor (__broadcast__) 也带 quota 三件 — 推给所有 client", () => { + setQuotaEnabled(true); + setTokenPlanApiKey("abcdefghij"); + const cidA = "q-A", cidB = "q-B"; + clients.set(cidA, makeClientState()); + clients.set(cidB, makeClientState()); + sseByCid.set(cidA, fakeSse()); + sseByCid.set(cidB, fakeSse()); + pushStateFor("__broadcast__"); + for (const cid of [cidA, cidB]) { + const payload = JSON.parse(sseByCid.get(cid).writes[0].slice(6)); + assert.equal(payload.quotaEnabled, true, `client ${cid} 收到 broadcast 必须带 quotaEnabled`); + assert.equal(payload.hasTokenPlanKey, true); + assert.equal(payload.tokenPlanApiKeyMasked, "sk-cp-...ghij"); + } + setQuotaEnabled(false); + setTokenPlanApiKey(""); + }); + + test("pushOnlineCount 带 quota 三件 — 在线数变化那次推送不能冲掉", () => { + setQuotaEnabled(true); + setTokenPlanApiKey("1234567890"); + const cid = "q-online"; + const cs = makeClientState(); + cs.workspace.dir = "/w"; + clients.set(cid, cs); + sseByCid.set(cid, fakeSse()); + pushOnlineCount(true); + const payload = JSON.parse(sseByCid.get(cid).writes[0].slice(6)); + assert.equal(payload.quotaEnabled, true, + "回归: 之前 pushOnlineCount 不带此字段, 多 tab 打开/关闭时 toggle 被打回"); + assert.equal(payload.hasTokenPlanKey, true); + assert.equal(payload.tokenPlanApiKeyMasked, "sk-cp-...7890"); + setQuotaEnabled(false); + setTokenPlanApiKey(""); + }); +}); + +// ============================================================ +// v2026-08-28 modacker (A+C): 外部 key 源优先级链 + source 字段 +// getTokenPlanApiKey() 优先级: env > file > settings.json +// 每次 SSE 推送必须带 tokenPlanApiKeySource + tokenPlanApiKeyFilePath +// 这两个新字段, webui 据此隐藏 "delete" 按钮 + 显示来源标签。 +// 这些测试同时也是 _setup.js mock 跟真实实现行为一致的契约。 +// ============================================================ +describe("v2026-08-28 modacker (A+C): external key source 优先级 + SSE 字段", () => { + function snapshotOf(cid) { + return JSON.parse(sseByCid.get(cid).writes[0].slice(6)); + } + + test("settings.json 路径: source = 'settings', 无 file path", () => { + setQuotaEnabled(true); + setTokenPlanApiKey("settings-key-1234"); + const cid = "src-1"; + clients.set(cid, makeClientState()); + sseByCid.set(cid, fakeSse()); + pushStateFor(cid); + const p = snapshotOf(cid); + assert.equal(p.tokenPlanApiKeySource, "settings"); + assert.equal(p.tokenPlanApiKeyFilePath, ""); + assert.equal(p.hasTokenPlanKey, true); + assert.equal(p.tokenPlanApiKeyMasked, "sk-cp-...1234"); + setTokenPlanApiKey(""); + setQuotaEnabled(false); + }); + + test("file 路径: source = 'file', 带 file path, 屏蔽 settings.json", () => { + setQuotaEnabled(true); + setTokenPlanApiKey("settings-key-1234"); + setFileTokenPlanKey("file-key-5678", "/tmp/token-plan.json"); + const cid = "src-2"; + clients.set(cid, makeClientState()); + sseByCid.set(cid, fakeSse()); + pushStateFor(cid); + const p = snapshotOf(cid); + assert.equal(p.tokenPlanApiKeySource, "file", + "file 路径应当胜过 settings.json (env 缺席时)"); + assert.equal(p.tokenPlanApiKeyFilePath, "/tmp/token-plan.json"); + assert.equal(p.hasTokenPlanKey, true); + assert.equal(p.tokenPlanApiKeyMasked, "sk-cp-...5678", + "masked 用 file key 算, 不是 settings key"); + // reset + setFileTokenPlanKey("", ""); + setTokenPlanApiKey(""); + setQuotaEnabled(false); + }); + + test("env 路径: source = 'env', 屏蔽 file + settings.json", () => { + setQuotaEnabled(true); + setTokenPlanApiKey("settings-key-1234"); + setFileTokenPlanKey("file-key-5678", "/tmp/token-plan.json"); + setEnvTokenPlanKey("env-key-9999"); + const cid = "src-3"; + clients.set(cid, makeClientState()); + sseByCid.set(cid, fakeSse()); + pushStateFor(cid); + const p = snapshotOf(cid); + assert.equal(p.tokenPlanApiKeySource, "env", + "env 应当胜过 file + settings.json, 是最高优先级"); + assert.equal(p.hasTokenPlanKey, true); + assert.equal(p.tokenPlanApiKeyMasked, "sk-cp-...9999", + "masked 用 env key 算, 不是 file/settings"); + // reset + setEnvTokenPlanKey(""); + setFileTokenPlanKey("", ""); + setTokenPlanApiKey(""); + setQuotaEnabled(false); + }); + + test("env 清空 → 自动降级到 file → 再降级到 settings.json", () => { + setQuotaEnabled(true); + setTokenPlanApiKey("settings-key-1234"); + setFileTokenPlanKey("file-key-5678", "/tmp/token-plan.json"); + setEnvTokenPlanKey("env-key-9999"); + const cid = "src-4"; + clients.set(cid, makeClientState()); + sseByCid.set(cid, fakeSse()); + // initial: env + pushStateFor(cid); + assert.equal(snapshotOf(cid).tokenPlanApiKeySource, "env"); + // 清 env — file 顶上 + sseByCid.get(cid).writes.length = 0; + setEnvTokenPlanKey(""); + pushStateFor(cid); + assert.equal(snapshotOf(cid).tokenPlanApiKeySource, "file", + "env 取消后, file 自动顶上 — 优先级链实时"); + // 清 file — settings 顶上 + sseByCid.get(cid).writes.length = 0; + setFileTokenPlanKey("", ""); + pushStateFor(cid); + assert.equal(snapshotOf(cid).tokenPlanApiKeySource, "settings", + "file 也取消后, settings.json 顶上"); + // reset + setTokenPlanApiKey(""); + setQuotaEnabled(false); + }); + + test("broadcast (pushStateFor __broadcast__) 同样带 source + file path", () => { + setQuotaEnabled(true); + setFileTokenPlanKey("file-key-aaaa", "/etc/webui/key.json"); + const cidA = "src-A", cidB = "src-B"; + clients.set(cidA, makeClientState()); + clients.set(cidB, makeClientState()); + sseByCid.set(cidA, fakeSse()); + sseByCid.set(cidB, fakeSse()); + pushStateFor("__broadcast__"); + for (const cid of [cidA, cidB]) { + const p = snapshotOf(cid); + assert.equal(p.tokenPlanApiKeySource, "file"); + assert.equal(p.tokenPlanApiKeyFilePath, "/etc/webui/key.json"); + } + setFileTokenPlanKey("", ""); + setQuotaEnabled(false); + }); + + test("无任何 key 时 source = '', hasTokenPlanKey = false", () => { + // explicit reset + setTokenPlanApiKey(""); + setFileTokenPlanKey("", ""); + setEnvTokenPlanKey(""); + setQuotaEnabled(false); + const cid = "src-empty"; + clients.set(cid, makeClientState()); + sseByCid.set(cid, fakeSse()); + pushStateFor(cid); + const p = snapshotOf(cid); + assert.equal(p.tokenPlanApiKeySource, ""); + assert.equal(p.hasTokenPlanKey, false); + assert.equal(p.tokenPlanApiKeyMasked, ""); + assert.equal(p.quotaEnabled, false); + }); +}); diff --git a/plugins/Wzdhehe/mcode-webui/test/usage.test.js b/plugins/Wzdhehe/mcode-webui/test/usage.test.js new file mode 100644 index 0000000..a970a2a --- /dev/null +++ b/plugins/Wzdhehe/mcode-webui/test/usage.test.js @@ -0,0 +1,187 @@ +// webui/test/usage.test.js +// Unit tests for server/lib/usage.js — parseTokenPlanResponse. +// +// Why this test exists: +// v2026-08-28 modacker: the first version of the parser read +// `data?.current_interval_remaining_percent` at the top level. +// The real Token Plan API actually nests that field inside +// `model_remains[i].current_interval_remaining_percent`. As a +// result, every fetch succeeded (HTTP 200) but the popover +// always showed "—" because `fiveHourPercent` stayed `null`. +// The user-visible symptom was "I added a key but the usage +// numbers don't show up." +// +// The fix: +// - Pick the `model_name === "general"` entry from model_remains[] +// (fallback to [0] if no general entry exists) +// - Read the percentage fields from THAT entry +// - Convert end_time (ms) → seconds for fiveHourReset +// +// The fixture below is a verbatim capture of the real API +// response on 2026-08-28, so any future shape change will +// trip the "values still come out as expected" assertions. + +import { test, describe, before, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { + setupMocks, + absPath, + registerAcpMock, + registerSessionsStore, +} from "./_setup.js"; + +let parseTokenPlanResponse; +let makeClientState; +let getTokenPlanApiKey; +let getQuotaEnabled; +let pushStateFor; +let sseByCid; +let clients; + +before(async (t) => { + await setupMocks(t); + const usageMod = await import(absPath("lib/usage.js")); + parseTokenPlanResponse = usageMod.parseTokenPlanResponse; + // We don't run runUsageQuery here (that needs a real fetch); we + // only exercise the pure parser. But we need makeClientState for + // the cs fixture. + const sbMod = await import(absPath("lib/state-bus.js")); + makeClientState = sbMod.makeClientState; + sseByCid = sbMod.sseByCid; + clients = sbMod.clients; + // settings mock — re-imported in usage.js + const settingsMod = await import(absPath("lib/settings.js")); + getTokenPlanApiKey = settingsMod.getTokenPlanApiKey; + getQuotaEnabled = settingsMod.getQuotaEnabled; + pushStateFor = sbMod.pushStateFor; +}); + +beforeEach(async () => { + // We don't run runUsageQuery (which has network side effects), + // so we don't need a real SSE client registered. But if a test + // ever switches to runUsageQuery, leave this here for the day. +}); + +// Real Token Plan API response captured 2026-08-28 via: +// curl -H "Authorization: Bearer " \ +// https://www.minimaxi.com/v1/token_plan/remains +// The numbers were 100% remaining because the user hadn't used +// any quota in the current window (cool-down). The point is the +// SHAPE — the parser needs to read from model_remains[0] (the +// "general" entry), not the top level. +const REAL_API_FIXTURE = { + model_remains: [ + { + start_time: 1787846400000, + end_time: 1787864400000, + remains_time: 9847188, + current_interval_total_count: 0, + current_interval_usage_count: 0, + model_name: "general", + current_weekly_total_count: 0, + current_weekly_usage_count: 0, + weekly_start_time: 1787500800000, + weekly_end_time: 1788105600000, + weekly_remains_time: 251047188, + current_interval_status: 1, + current_interval_remaining_percent: 100, + // v2026-08-28 modacker: field ends with `percent`, not `pct`. + // The first version of the parser read `current_weekly_remaining_pct` + // and silently got undefined. This fixture pins the real wire shape. + current_weekly_remaining_percent: 100, + }, + ], +}; + +describe("parseTokenPlanResponse — 真实 API 响应 (model_remains[0] 路径)", () => { + test("general model 字段被正确提取, 不再是 null", () => { + const cs = { usage: {} }; + const result = parseTokenPlanResponse(REAL_API_FIXTURE, cs); + assert.equal(result.ok, true); + assert.equal(cs.usage.fiveHourPercent, 100, + "回归: 之前读 top-level data.current_interval_remaining_percent 总是 undefined → 前端显示 '—'"); + assert.equal(cs.usage.weekly, "100%"); + }); + + test("end_time (ms) 转为 seconds 给 fiveHourReset", () => { + const cs = { usage: {} }; + parseTokenPlanResponse(REAL_API_FIXTURE, cs); + assert.equal(cs.usage.fiveHourReset, 1787864400, + "fiveHourReset 是 unix 秒 (前端 nextFiveHourReset 期望), 不是 ms"); + }); + + test("非 general model 入口(只有 video 类), 也能 fallback 到 model_remains[0]", () => { + const fixtureNoGeneral = { + model_remains: [ + { + model_name: "video", + current_interval_remaining_percent: 42.5, + current_weekly_remaining_percent: 60.0, + end_time: 1787864400000, + }, + ], + }; + const cs = { usage: {} }; + const r = parseTokenPlanResponse(fixtureNoGeneral, cs); + assert.equal(r.ok, true); + assert.equal(cs.usage.fiveHourPercent, 42.5, + "没 general 入口时, fallback 到 [0] — 而不是返回 null 让前端空着"); + assert.equal(cs.usage.weekly, "60%"); + }); + + test("缺字段时返回 null, 不抛错", () => { + const cs = { usage: {} }; + const r = parseTokenPlanResponse({ model_remains: [{}] }, cs); + assert.equal(r.ok, true); + assert.equal(cs.usage.fiveHourPercent, null); + assert.equal(cs.usage.weekly, null); + assert.equal(cs.usage.fiveHourReset, null); + }); + + test("base_resp.status_code !== 0 → 返回 ok:false + error, 不污染 cs", () => { + const cs = { usage: {} }; + const r = parseTokenPlanResponse( + { base_resp: { status_code: 1004, status_msg: "login fail: ..." } }, + cs, + ); + assert.equal(r.ok, false); + assert.match(r.error, /login fail/); + assert.equal(cs.usage.fiveHourPercent, undefined, + "出错时不写 partial 状态, 调用方根据 r.ok 决定写 error / hidden 字段"); + }); + + test("model_remains 不是数组时, 不崩, 字段都是 null", () => { + const cs = { usage: {} }; + const r = parseTokenPlanResponse({ model_remains: "garbage" }, cs); + assert.equal(r.ok, true); + assert.equal(cs.usage.fiveHourPercent, null); + assert.equal(cs.usage.weekly, null); + }); +}); + +describe("parseTokenPlanResponse — 历史假象 (老 parser bug)", () => { + // v0.5.ap 之前版本的 parser 期望字段在顶层, 即 + // data.current_interval_remaining_percent / data.weekly_remaining_pct + // 它实际从不被使用, 总是 null。 这里是保险测试, 确保我们新 parser + // 不会"读顶层" (即使 API 加了顶层字段作为冗余也不会被误用)。 + test("顶层 current_interval_remaining_percent 不被使用 (即使 API 同时返了它)", () => { + const fixture = { + // 顶层字段 (假 — 真实 API 不返) + current_interval_remaining_percent: 99, + current_weekly_remaining_percent: 99, + // 真实 API 返的嵌套字段 + model_remains: [{ + model_name: "general", + current_interval_remaining_percent: 50, + current_weekly_remaining_percent: 60, + end_time: 1787864400000, + }], + }; + const cs = { usage: {} }; + parseTokenPlanResponse(fixture, cs); + // 必须从嵌套读, 不是从顶层 + assert.equal(cs.usage.fiveHourPercent, 50, + "若读顶层 (99), 用户看到的 5h 跟 weekly 不一致就出现幻象"); + assert.equal(cs.usage.weekly, "60%"); + }); +});