From 1f810209c17b01a3f8fbef0f03e7c8fd6762eae0 Mon Sep 17 00:00:00 2001 From: ocoomber Date: Mon, 21 Sep 2026 02:01:56 +0100 Subject: [PATCH 1/5] Add Model Manager mini app by ocoomber A MiniApp for browsing, searching, and enabling/disabling models in ~/.minimax/config.yaml directly from MiniMax Code. - Works with any provider (OpenRouter, custom, local endpoints) - Instant save per toggle; bulk enable/disable with one-level undo - Automatic timestamped backups before bulk changes - Atomic, line-based YAML editing preserving indentation and line endings - Restart reminder banner; OpenRouter model page links - English and Simplified Chinese READMEs; MIT licensed --- README.md | 1 + README.zh-CN.md | 1 + .../.minimax-plugin/plugin.json | 14 + .../ocoomber/openrouter-model-manager/LICENSE | 21 + .../openrouter-model-manager/README.md | 59 ++ .../openrouter-model-manager/README.zh-CN.md | 59 ++ .../openrouter-model-manager/icon.png | Bin 0 -> 1574 bytes .../miniapp/client/index.html | 651 ++++++++++++++++++ .../miniapp/miniapp.json | 20 + .../miniapp/node/miniapp-api.ts | 109 +++ .../miniapp/node/server.mjs | 404 +++++++++++ .../openrouter-model-manager/package.json | 6 + 12 files changed, 1345 insertions(+) create mode 100644 plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json create mode 100644 plugins/ocoomber/openrouter-model-manager/LICENSE create mode 100644 plugins/ocoomber/openrouter-model-manager/README.md create mode 100644 plugins/ocoomber/openrouter-model-manager/README.zh-CN.md create mode 100644 plugins/ocoomber/openrouter-model-manager/icon.png create mode 100644 plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html create mode 100644 plugins/ocoomber/openrouter-model-manager/miniapp/miniapp.json create mode 100644 plugins/ocoomber/openrouter-model-manager/miniapp/node/miniapp-api.ts create mode 100644 plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs create mode 100644 plugins/ocoomber/openrouter-model-manager/package.json diff --git a/README.md b/README.md index 27a958e..39fe8a6 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Packages are organized by author under `plugins///`. | --- | --- | --- | | [Token Usage Board](plugins/amszuidas/mcode-token-usage-board/) | Explore local Token usage by date, model, and session, including input, output, and cache usage | [amszuidas](https://github.com/amszuidas) | | [Token Usage Board](plugins/yanhy2000/mcode-usage-monitor/) | Watch local Token usage, output speed, and cache hit rate in near real time; filter by time range, model, and session | [yanhy2000](https://github.com/yanhy2000) | +| [Model Manager](plugins/ocoomber/openrouter-model-manager/) | Browse, search, and enable/disable models in your `~/.minimax/config.yaml` with instant save, bulk actions, one-click undo, and automatic backups | [ocoomber](https://github.com/ocoomber) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index 4c0c796..204fa2f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -18,6 +18,7 @@ | --- | --- | --- | | [Token 用量看板](plugins/amszuidas/mcode-token-usage-board/README.zh-CN.md) | 按时间、模型和会话查看本机 Token 用量,包含输入、输出和缓存用量 | [amszuidas](https://github.com/amszuidas) | | [Token 用量看板](plugins/yanhy2000/mcode-usage-monitor/README.zh-CN.md) | 近实时查看本机 Token 用量、输出速度与缓存命中率,可按时间范围、模型和会话筛选 | [yanhy2000](https://github.com/yanhy2000) | +| [模型管理器](plugins/ocoomber/openrouter-model-manager/README.zh-CN.md) | 浏览、搜索并启用/停用 `~/.minimax/config.yaml` 中的模型,支持即时保存、批量操作、一键撤销和自动备份 | [ocoomber](https://github.com/ocoomber) |
预览:Token 用量看板 diff --git a/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json b/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json new file mode 100644 index 0000000..36ee4a8 --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "name": "openrouter-model-manager", + "displayName": "Model Manager", + "version": "1.2.1", + "description": "Browse, search, and enable/disable the models in your MiniMax Code config.yaml — works with any provider (OpenRouter, custom, locally hosted), with bulk actions, one-level undo, and OpenRouter links.", + "author": "ocoomber", + "icon": "icon.png", + "category": "Other", + "exampleQueries": [], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/ocoomber/openrouter-model-manager/LICENSE b/plugins/ocoomber/openrouter-model-manager/LICENSE new file mode 100644 index 0000000..c9f678b --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ocoomber + +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/ocoomber/openrouter-model-manager/README.md b/plugins/ocoomber/openrouter-model-manager/README.md new file mode 100644 index 0000000..9ed9bfd --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/README.md @@ -0,0 +1,59 @@ +# Model Manager (`openrouter-model-manager`) + +English | [简体中文](README.zh-CN.md) + +A [MiniMax Code](https://github.com/MiniMax-AI) Mini App for browsing, searching, and enabling/disabling the models in your `~/.minimax/config.yaml` — no more find-and-replace in Notepad. + +Author: [ocoomber](https://github.com/ocoomber) · Version: `1.2.1` + +## What it does + +- **Works with any provider** — OpenRouter, custom providers, and locally hosted endpoints (Ollama, LM Studio). With more than one provider in your config, a dropdown appears to switch between them; with a single provider it stays out of the way. +- **Restart reminder** — a banner appears as soon as you change anything, reminding you to restart MiniMax Code (mini apps can't restart the host app for you). +- **Search** across model IDs and display names. +- **Instant save** — every toggle writes to your config immediately; no save button. +- **Filter chips** — All / Enabled only / Disabled only. +- **Bulk actions** — *Enable matching* / *Disable matching* apply only to the current search results, and each model family has its own enable/disable buttons. +- **One-level Undo** — made a mistake with "enable all"? One click restores the previous config. +- **Automatic backups** — before every bulk change, a timestamped copy of your config is written to the plugin's data folder (`backups/`). +- **Collapsible families** — models are grouped by the prefix before the `/` in their ID. +- **OpenRouter links** — every model row can link to its OpenRouter page (shown only for OpenRouter providers). Right-click a link to choose the external browser, the built-in browser, or copy the URL. +- **Context-limit badges** — read straight from your config. + +## Install + +Copy (or clone) this folder into your MiniMax Code plugins directory: + +``` +~/.minimax/plugins/openrouter-model-manager/ +``` + +Then restart MiniMax Code and open the **Model Manager** mini app. + +> Note: after toggling models, restart MiniMax Code itself for the change to take effect — the config is read at startup. + +## How it works + +The Node runtime reads `~/.minimax/config.yaml` line by line (no YAML library) and finds every `models:` block that has `enabled:` flags. Toggling a model rewrites only that model's `enabled:` line. All writes are atomic (temp file + rename), and your file's existing indentation and line endings are preserved. + +Capabilities such as vision support are intentionally **not** fetched from external APIs — your config file is the single source of truth. + +## Privacy + +The app reads and writes only your `~/.minimax/config.yaml` (backups go to the plugin's own data folder). It makes no network requests and never displays API keys — secrets stay hidden in the UI. + +## Files + +``` +.minimax-plugin/plugin.json Plugin manifest +package.json Mini app bootstrap +miniapp/miniapp.json Mini app surface/runtime config +miniapp/client/index.html UI (light/dark aware) +miniapp/node/server.mjs Node runtime + REST API +miniapp/node/miniapp-api.ts Type declarations for the runtime API +icon.png Plugin icon +``` + +## License + +[MIT](./LICENSE) diff --git a/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md b/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md new file mode 100644 index 0000000..67c971f --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md @@ -0,0 +1,59 @@ +# 模型管理器(`openrouter-model-manager`) + +[English](README.md) | 简体中文 + +一个 [MiniMax Code](https://github.com/MiniMax-AI) Mini App,用于浏览、搜索并启用/停用 `~/.minimax/config.yaml` 中的模型 —— 不用再在记事本里查找替换了。 + +作者:[ocoomber](https://github.com/ocoomber) · 版本:`1.2.1` + +## 功能 + +- **支持任意提供商** —— OpenRouter、自定义提供商,以及本地服务(Ollama、LM Studio)。配置里有多个提供商时会出现下拉框用于切换;只有一个时自动隐藏,不占地方。 +- **重启提醒** —— 一旦有任何改动,页面会出现醒目的横幅,提醒你需要重启 MiniMax Code(Mini App 无法代替你重启宿主程序)。 +- **搜索** —— 同时匹配模型 ID 和显示名称。 +- **即时保存** —— 每次切换立即写入配置,没有保存按钮。 +- **筛选** —— 全部 / 仅启用 / 仅停用。 +- **批量操作** —— "启用匹配项 / 停用匹配项"只作用于当前搜索结果;每个模型家族也有自己的启用/停用按钮。 +- **一步撤销** —— 批量开启后后悔了?点一下即可恢复上一个配置。 +- **自动备份** —— 每次批量改动前,都会在插件数据目录的 `backups/` 里保存一份带时间戳的配置副本。 +- **可折叠的模型家族** —— 模型按 ID 中 `/` 之前的前缀分组;家族内有已启用的模型时,折叠状态会显示绿点。 +- **OpenRouter 链接** —— 每个模型行可跳转到 OpenRouter 页面(仅 OpenRouter 提供商显示)。右键链接可选择外部浏览器、内置浏览器或复制网址。 +- **上下文长度徽标** —— 直接读取自你的配置。 + +## 安装 + +将本目录完整复制(或克隆)到 MiniMax Code 的插件目录: + +``` +~/.minimax/plugins/openrouter-model-manager/ +``` + +保留 `.minimax-plugin` 隐藏目录。重启 MiniMax Code,确认插件已被识别,然后打开"模型管理器",或在对话中说"打开模型管理器"。 + +> 注意:切换模型之后,需要重启 MiniMax Code 本体才能生效 —— 配置是在启动时读取的。 + +## 工作原理 + +Node 运行时逐行读取 `~/.minimax/config.yaml`(不依赖 YAML 库),找出所有带 `enabled:` 开关的 `models:` 块。切换某个模型时只改写该模型的 `enabled:` 一行。所有写入都是原子性的(先写临时文件再重命名),并完整保留你文件原有的缩进和换行符。模型 ID 解析兼容 `llama3.1:latest`、`:free` 这类带冒号的写法。 + +视觉支持等模型能力刻意不从外部 API 获取 —— 配置文件是唯一数据来源。 + +## 隐私 + +应用只读写你的 `~/.minimax/config.yaml`(备份保存在插件自己的数据目录)。不发起任何网络请求,也不会显示 API Key —— 密钥在界面中始终隐藏。 + +## 文件结构 + +``` +.minimax-plugin/plugin.json 插件清单 +package.json Mini App 引导文件 +miniapp/miniapp.json Mini App 界面/运行时配置 +miniapp/client/index.html 界面(自动适配亮/暗色) +miniapp/node/server.mjs Node 运行时 + REST API +miniapp/node/miniapp-api.ts 运行时 API 类型声明 +icon.png 插件图标 +``` + +## 许可证 + +[MIT](./LICENSE) diff --git a/plugins/ocoomber/openrouter-model-manager/icon.png b/plugins/ocoomber/openrouter-model-manager/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..299a26cbf5c1427dffa9cd575cf721307d570765 GIT binary patch literal 1574 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV7=|>;uumf=gr;N9Fb5F_7BDH zN*t40jx3m@A#jD`h{82~A;W#9hIKB=6V;QM-fp_!Iw3Du_^7z>7spdwD|5UT=(0U- z4vcs42{>&0A!&-l#UFng7Vv9zUfN`Qd~;|s@7+uz9O)JMQ;7Q z+VlNc*1f)P;(=w;neIK@5%Dq?bm#T^{r{9*@ia!gnj^~T2}4Q1*Th_Vu`>}GpIDCO z#4fN@Vt4rd)BClU(Q~fW#@QT3`o97jTK?xMOo;EM)AkEGZ>^la!>4}Tg|#orV!ID- z4G8cmi29Kul*I@Nov(+hUWIHbj*XAkN|#&6e?ev6^ZSxvcK?~yFA_-*H<;a0#(2g^ zzl72KdH~l2CW&?ibaY*)m{GcGsq4HCVQW8`+3MMAA9>%$$6x1gcEXjIh6=|UTHH*2 zPm^w)+0I})`*&FN5&Nu1Y+r6yc*L8(Sa9m$yrfKZ#~fa1&NHPdC8Y+RdyG?WF};29 zt~O0_E62oyX%mAzpJu)CiI9%EV`HT-k0aLM2a{@p^XrK&uP*%0esO=1w)UU=+zCGMvNmy= z|6P>ouW5@p2v)su`cxCfV?$ymt*7pfdX*%ncOdCuPzej4`!;F|NW(R z>9S1?D}6X`#NYh7GVkf}bgjz@`%TThovQS5e3ma}8>P8_foSNz|MS*u5I(ouy{)ZH zDAVlt_qYF?TCQcTsM{ODK6ko9)iJH7$x{Rh??u)A%=k6?)3WE!yQWM!akgaN_W!DX z-#&jmHN3X`^}*WR`-q(SpO3zY`t01fRdCqjHPcc83_B1GbPU2zNIsfpf)(T$7XRi5-O#h75#2n`^6HJMF!qC&lGw+h(&G*h? zk*k_rMVov+YqaeSTOYYZzjSt0F6+COd#ca&eqZ;_rtH&`lb%gyI!#lfHFBb5N}kls z$lZDCNY^>py$dH>z2A9YX;J*2GNpMejVH=I&vYJ{xB2|{X>r$mYq=dibJoiERAfK) zQ9W)K_T5@<--_8kB%7Wkzk0OuD!*pi4Mquhsb5FS-xaZWR zZ|9tq^y-Rf{wEFBD;u8QSW~|%f9dnxxlsofN1ZNZ>HPit!>su-YkBth3z|N4bh|s- zYxS08(KkMRy%ipDKxc+#k7En7+RrklI~Sdmo1bg6`PiN0n#CTy2vIV@izrM+Ju?Ht m|NpbhtN4MXJsYT)XJ&Z(ZtAoneFio_5e83JKbLh*2~7YK=FPVN literal 0 HcmV?d00001 diff --git a/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html b/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html new file mode 100644 index 0000000..953b911 --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html @@ -0,0 +1,651 @@ + + + + + +Model Manager + + + +
+

Model Manager

+

🔄 Changes here don't apply until you restart MiniMax Code

+ +
+
+ + + Loading… + + + + + +
+
+ + + + +
+ +
+ +

+ +
+
+ + + + diff --git a/plugins/ocoomber/openrouter-model-manager/miniapp/miniapp.json b/plugins/ocoomber/openrouter-model-manager/miniapp/miniapp.json new file mode 100644 index 0000000..498d880 --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/miniapp/miniapp.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/dashboard" + }, + "mcpEndpoints": [] +} diff --git a/plugins/ocoomber/openrouter-model-manager/miniapp/node/miniapp-api.ts b/plugins/ocoomber/openrouter-model-manager/miniapp/node/miniapp-api.ts new file mode 100644 index 0000000..7a0e0fd --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/miniapp/node/miniapp-api.ts @@ -0,0 +1,109 @@ +/** + * Agent-facing Mini App runtime authoring declarations. + * + * Copy this file into a generated plugin for type checking. It contains no Host implementation; + * the Host injects runtime values through start(context). + * Keep the .ts filename: Electron packaging excludes .d.ts files from dependency assets. + */ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: 'HostConnectorToolRef'; +}; + +export interface HostConnectorTool { + readonly toolRef: HostConnectorToolRef; + readonly provider: string; + readonly name: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly outputSchema?: JsonValue; +} + +export interface HostConnectorListResult { + readonly tools: readonly HostConnectorTool[]; + readonly partial: boolean; +} + +export interface HostConnectorCallOptions { + readonly signal?: AbortSignal; +} + +export interface HostConnectorCallResult { + readonly invocationId: string; + /** + * Raw provider result; it is not normalized by the Host and may be an object, array, or primitive. + * A single text-block array is one provider shape, not a global Host transport contract. + * Decode only a probe-observed envelope; preserve every other value, including direct strings. + */ + readonly value: JsonValue; +} + +export interface HostConnectorClient { + /** Candidate-safe inventory only; available before and after activation. */ + list(options?: HostConnectorCallOptions): Promise; + /** Activation-only business dispatch; call from request handling, never start(context). */ + call( + toolRef: HostConnectorToolRef, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; +} + +export type HostConnectorErrorDisposition = + | 'not_dispatched' + | 'provider_reported' + | 'unknown_after_dispatch'; + +export type HostConnectorErrorCode = + | 'TOOL_REF_STALE' + | 'SERVICE_RESTARTED' + | 'REQUEST_CANCELLED' + | 'CONNECTOR_TIMEOUT' + | 'INVALID_ARGUMENTS' + | 'CONNECTOR_PROVIDER_ERROR' + | 'CONNECTOR_UNAVAILABLE' + | 'CONNECTOR_OUTCOME_UNKNOWN'; + +export interface HostConnectorError extends Error { + readonly code: HostConnectorErrorCode; + readonly disposition: HostConnectorErrorDisposition; + readonly retryable: boolean; + readonly invocationId?: string; + readonly diagnostic?: { + readonly issues: readonly { + readonly path: string; + readonly constraint: string; + readonly limit?: number; + }[]; + }; +} + +export interface MiniAppLogger { + debug(message: string, fields?: JsonObject): void; + info(message: string, fields?: JsonObject): void; + warn(message: string, fields?: JsonObject): void; + error(message: string, fields?: JsonObject): void; +} + +export interface MiniAppLifecycle { + dispose(): void | Promise; +} + +export interface MiniAppContext { + readonly pluginId: string; + readonly pluginRoot: string; + readonly dataDir: string; + readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; + readonly signal: AbortSignal; + readonly logger: MiniAppLogger; + readonly hostConnector?: HostConnectorClient; +} + +export interface MiniAppModule { + /** Resolve only after the listener accepts connections and every route is installed. */ + start(context: MiniAppContext): Promise; +} diff --git a/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs b/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs new file mode 100644 index 0000000..f0d7882 --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs @@ -0,0 +1,404 @@ +// Model Manager — Mini App Node runtime +// Edits enabled: flags for models in the MiniMax Code config.yaml. +// Discovers any provider block containing discovered models (OpenRouter, +// custom providers, locally hosted endpoints such as Ollama/LM Studio). + +import { readFile, writeFile, rename, mkdir } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join, dirname } from 'node:path'; +import { homedir, platform } from 'node:os'; +import { spawn } from 'node:child_process'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + +const CONFIG_PATH = join(homedir(), '.minimax', 'config.yaml'); + +async function readConfigText() { + return readFile(CONFIG_PATH, 'utf8'); +} + +/** Split config text into lines, remembering the dominant EOL so writes preserve it. */ +export function splitConfigText(text) { + return { + lines: text.split(/\r?\n/), + eol: text.includes('\r\n') ? '\r\n' : '\n', + }; +} + +async function writeConfigLines(lines, eol) { + const tmp = join(dirname(CONFIG_PATH), '.config.yaml.mm-tmp'); + await writeFile(tmp, lines.join(eol), 'utf8'); + await rename(tmp, CONFIG_PATH); +} + +/** + * Parse one YAML-ish line: a key ends at the first ':' followed by + * whitespace or end-of-line (the real YAML rule, so keys like + * `llama3.1:latest` or `deepseek/deepseek-chat-v3.1:free` still parse). + * Returns { indent, key, value } or null for lines with no key separator. + */ +function splitKeyLine(line) { + const m = line.match(/^(\s*)(.*)$/); + const rest = m[2]; + if (!rest || rest.startsWith('#')) return null; + const sep = rest.search(/:(\s|$)/); + if (sep === -1) return null; + const key = rest.slice(0, sep).replace(/^["']|["']$/g, '').trim(); + if (!key) return null; + let value = rest.slice(sep + 1).trim(); + if (value.startsWith('#')) value = ''; // `key:` with only a trailing comment + return { indent: m[1].length, key, value }; +} + +/** + * Walk the YAML lines and find every "models:" block whose entries look like + * MiniMax Code model definitions: model keys two indentation levels under + * "models:", enabled:/name: one more level in, context: one more again. + * Returns providers: { path: [...ancestor keys], label, models }. + * Purely line/indent based — no YAML library needed. + */ +export function parseProviders(lines) { + const stack = []; // { indent, key } + const providers = []; + for (let i = 0; i < lines.length; i++) { + const k = splitKeyLine(lines[i]); + if (!k || k.value !== '') continue; + while (stack.length && stack[stack.length - 1].indent >= k.indent) stack.pop(); + stack.push({ indent: k.indent, key: k.key }); + if (k.key !== 'models') continue; + const modelsIndent = k.indent; + const models = []; + let current = null; + for (let j = i + 1; j < lines.length; j++) { + if (lines[j].trim() === '') continue; + const c = splitKeyLine(lines[j]); + if (!c) continue; + if (c.indent <= modelsIndent) break; // left the models block + const rel = c.indent - modelsIndent; + if (rel === 2 && c.value === '') { + current = { id: c.key, enabledIndex: -1, enabled: false, name: '', contextLimit: 0 }; + models.push(current); + continue; + } + if (!current) continue; + if (rel === 4 && c.key === 'enabled') { + const vm = c.value.match(/^(true|false)\b/); + if (vm) { current.enabledIndex = j; current.enabled = vm[1] === 'true'; } + continue; + } + if (rel === 4 && c.key === 'name') { + current.name = c.value.replace(/^['"]|['"]$/g, ''); + continue; + } + if (rel === 6 && c.key === 'context' && /^\d+$/.test(c.value)) { + current.contextLimit = parseInt(c.value, 10); + continue; + } + } + if (models.some((x) => x.enabledIndex !== -1)) { + // provider label = ancestor keys of the models block (skip 'models' itself) + const pathKeys = stack.slice(0, -1).map((s) => s.key).filter((n) => n !== 'models'); + const label = pathKeys.length ? pathKeys.join(' / ') : 'models'; + providers.push({ path: pathKeys, label, models: models.filter((x) => x.enabledIndex !== -1) }); + } + } + return providers; +} + +function findProvider(providers, index) { + if (typeof index !== 'number' || Number.isNaN(index) || index < 0 || index >= providers.length) return null; + return providers[index]; +} + +/** One-level undo: raw config text taken before the most recent mutation. */ +let lastSnapshot = null; + +async function takeSnapshot(text, withBackup, dataDir) { + lastSnapshot = text; + if (withBackup && dataDir) { + try { + const backupDir = join(dataDir, 'backups'); + await mkdir(backupDir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + await writeFile(join(backupDir, `config-${stamp}.yaml`), text, 'utf8'); + } catch { + // backup is best-effort; never block the mutation + } + } +} + +async function undo() { + if (lastSnapshot === null) return { undone: false }; + const tmp = join(dirname(CONFIG_PATH), '.config.yaml.mm-tmp'); + await writeFile(tmp, lastSnapshot, 'utf8'); + await rename(tmp, CONFIG_PATH); + lastSnapshot = null; + return { undone: true }; +} + +function currentEnabled(line) { + const m = line.match(/enabled:\s*(true|false)/); + return m ? m[1] : null; +} + +function setEnabledOnLine(line, enabled) { + return line.replace(/(enabled:\s*)(true|false)/, `$1${enabled}`); +} + +async function setModelEnabled(providerIndex, modelId, enabled) { + const text = await readConfigText(); + const { lines, eol } = splitConfigText(text); + const provider = findProvider(parseProviders(lines), providerIndex); + if (!provider) throw new Error('Provider not found'); + const model = provider.models.find((m) => m.id === modelId); + if (!model) throw new Error(`Unknown model: ${modelId}`); + const line = lines[model.enabledIndex]; + if (currentEnabled(line) === String(enabled)) return { changed: false }; + await takeSnapshot(text, false); + lines[model.enabledIndex] = setEnabledOnLine(line, enabled); + await writeConfigLines(lines, eol); + return { changed: true }; +} + +async function setModelsEnabled(providerIndex, ids, enabled, dataDir) { + const text = await readConfigText(); + const { lines, eol } = splitConfigText(text); + const provider = findProvider(parseProviders(lines), providerIndex); + if (!provider) throw new Error('Provider not found'); + const wanted = new Set(ids); + const touched = provider.models.filter( + (m) => wanted.has(m.id) && currentEnabled(lines[m.enabledIndex]) !== String(enabled), + ); + if (touched.length > 0) { + await takeSnapshot(text, true, dataDir); + for (const m of touched) { + lines[m.enabledIndex] = setEnabledOnLine(lines[m.enabledIndex], enabled); + } + await writeConfigLines(lines, eol); + } + return touched.length; +} + +function json(response, status, payload) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify(payload)); +} + +async function readJsonBody(request, response, maxBytes = 1024 * 1024) { + let body = ''; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > maxBytes) { + json(response, 413, { error: 'payload_too_large' }); + return null; + } + body += chunk; + } + if (!body) return {}; + try { + const parsed = JSON.parse(body); + if (parsed === null || typeof parsed !== 'object') { + json(response, 400, { error: 'invalid_json_body' }); + return null; + } + return parsed; + } catch { + json(response, 400, { error: 'invalid_json_body' }); + return null; + } +} + +/** Open a URL in the OS default browser (Windows / macOS / Linux), + * falling back through launch strategies until one spawns cleanly. */ +function openExternal(url, logger) { + let strategies; + const p = platform(); + if (p === 'win32') { + strategies = [ + ['rundll32.exe', ['url.dll,FileProtocolHandler', url]], + ['cmd.exe', ['/c', 'start', '', url]], + ['explorer.exe', [url]], + ]; + } else if (p === 'darwin') { + strategies = [['open', [url]]]; + } else { + strategies = [['xdg-open', [url]]]; + } + const tryNext = (i) => { + if (i >= strategies.length) { + logger.error('miniapp.open_external.failed', { message: 'all launch strategies failed', url }); + return; + } + const [cmd, args] = strategies[i]; + let child; + try { + child = spawn(cmd, args, { stdio: 'ignore', windowsHide: true }); + } catch (error) { + logger.error('miniapp.open_external.retry', { cmd, message: String(error?.message ?? error) }); + tryNext(i + 1); + return; + } + child.on('error', (error) => { + logger.error('miniapp.open_external.retry', { cmd, message: String(error?.message ?? error) }); + tryNext(i + 1); + }); + child.unref(); + }; + tryNext(0); +} + +export async function start(context) { + const clientIndex = await readFile( + join(context.pluginRoot, 'miniapp/client/index.html'), + 'utf8', + ); + + const server = createServer(async (request, response) => { + const url = new URL(request.url ?? '/', 'http://miniapp.local'); + const route = `${request.method} ${url.pathname}`; + + try { + if (request.method === 'GET' && url.pathname === '/dashboard') { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + response.end(clientIndex); + return; + } + + // List providers that contain manageable models. + if (request.method === 'GET' && url.pathname === '/api/providers') { + const { lines } = splitConfigText(await readConfigText()); + const providers = parseProviders(lines); + json(response, 200, { + configPath: CONFIG_PATH, + providers: providers.map((p, i) => ({ + index: i, + label: p.label, + modelCount: p.models.length, + enabledCount: p.models.filter((m) => m.enabled).length, + })), + }); + return; + } + + // Models for one provider. + if (request.method === 'GET' && url.pathname === '/api/models') { + const providerIndex = parseInt(url.searchParams.get('provider') ?? '0', 10); + const { lines } = splitConfigText(await readConfigText()); + const provider = findProvider(parseProviders(lines), providerIndex); + if (!provider) { + json(response, 404, { error: 'provider_not_found' }); + return; + } + const isOpenRouter = /openrouter/i.test(provider.label); + json(response, 200, { + provider: provider.label, + isOpenRouter, + models: provider.models.map((m) => ({ + id: m.id, + name: m.name, + enabled: m.enabled, + contextLimit: m.contextLimit, + })), + }); + return; + } + + if (request.method === 'POST' && url.pathname === '/api/set') { + const body = await readJsonBody(request, response); + if (body === null) return; + const { provider, model, enabled } = body; + if (typeof model !== 'string' || model.length === 0 || typeof enabled !== 'boolean') { + json(response, 400, { error: 'invalid_arguments' }); + return; + } + const result = await setModelEnabled(typeof provider === 'number' ? provider : 0, model, enabled); + json(response, 200, result); + return; + } + + // Bulk update scoped to the given model ids of one provider. + if (request.method === 'POST' && url.pathname === '/api/bulk') { + const body = await readJsonBody(request, response); + if (body === null) return; + const { provider, enabled, models } = body; + if (typeof enabled !== 'boolean' || !Array.isArray(models) || + !models.every((x) => typeof x === 'string' && x.length > 0)) { + json(response, 400, { error: 'invalid_arguments' }); + return; + } + const changedCount = await setModelsEnabled( + typeof provider === 'number' ? provider : 0, + [...new Set(models)], + enabled, + context.dataDir, + ); + json(response, 200, { changedCount }); + return; + } + + // One-level undo of the most recent mutation. + if (request.method === 'POST' && url.pathname === '/api/undo') { + const result = await undo(); + json(response, 200, result); + return; + } + + // Open an OpenRouter model page in the user's external browser. + if (request.method === 'POST' && url.pathname === '/api/open-external') { + const body = await readJsonBody(request, response); + if (body === null) return; + const target = typeof body.url === 'string' ? body.url : ''; + const ok = /^https:\/\/openrouter\.ai\/[A-Za-z0-9_.~-]+\/[A-Za-z0-9_.~:-]+$/.test(target); + if (!ok) { + json(response, 400, { error: 'url_not_allowed' }); + return; + } + openExternal(target, context.logger); + json(response, 200, { opened: true }); + return; + } + + json(response, 404, { error: 'not_found' }); + } catch (error) { + context.logger.error('miniapp.request.failed', { route, message: String(error?.message ?? error) }); + json(response, 500, { error: 'internal_error', message: String(error?.message ?? error) }); + } + }); + + await listen(server, context.listen.host, context.listen.port); + context.logger.info('miniapp.runtime.listening'); + + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + context.signal.removeEventListener('abort', onAbort); + await close(server); + }; + const onAbort = () => { + void dispose(); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} + +function listen(server, host, port) { + return new Promise((resolve, reject) => { + const onError = (error) => reject(error); + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + resolve(); + }); + }); +} + +function close(server) { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} diff --git a/plugins/ocoomber/openrouter-model-manager/package.json b/plugins/ocoomber/openrouter-model-manager/package.json new file mode 100644 index 0000000..aab6b8d --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} From e8e462a4f3d7b9cd658fade05371eec315db7344 Mon Sep 17 00:00:00 2001 From: ocoomber Date: Mon, 21 Sep 2026 02:22:25 +0100 Subject: [PATCH 2/5] Remove provider dropdown; merge all model blocks into one list (v1.2.2) The UI navigates by model family (ID prefix) only. All manageable model blocks in config.yaml are merged into a single family-grouped list; blocks without enabled flags (e.g. the built-in always-on provider) are skipped as before. --- .../.minimax-plugin/plugin.json | 2 +- .../openrouter-model-manager/README.md | 4 +- .../openrouter-model-manager/README.zh-CN.md | 4 +- .../miniapp/client/index.html | 122 +++++++----------- 4 files changed, 53 insertions(+), 79 deletions(-) diff --git a/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json b/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json index 36ee4a8..61f4d62 100644 --- a/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json +++ b/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "openrouter-model-manager", "displayName": "Model Manager", - "version": "1.2.1", + "version": "1.2.2", "description": "Browse, search, and enable/disable the models in your MiniMax Code config.yaml — works with any provider (OpenRouter, custom, locally hosted), with bulk actions, one-level undo, and OpenRouter links.", "author": "ocoomber", "icon": "icon.png", diff --git a/plugins/ocoomber/openrouter-model-manager/README.md b/plugins/ocoomber/openrouter-model-manager/README.md index 9ed9bfd..acc3bdd 100644 --- a/plugins/ocoomber/openrouter-model-manager/README.md +++ b/plugins/ocoomber/openrouter-model-manager/README.md @@ -4,11 +4,11 @@ English | [简体中文](README.zh-CN.md) A [MiniMax Code](https://github.com/MiniMax-AI) Mini App for browsing, searching, and enabling/disabling the models in your `~/.minimax/config.yaml` — no more find-and-replace in Notepad. -Author: [ocoomber](https://github.com/ocoomber) · Version: `1.2.1` +Author: [ocoomber](https://github.com/ocoomber) · Version: `1.2.2` ## What it does -- **Works with any provider** — OpenRouter, custom providers, and locally hosted endpoints (Ollama, LM Studio). With more than one provider in your config, a dropdown appears to switch between them; with a single provider it stays out of the way. +- **Works with any provider** — OpenRouter, custom providers, and locally hosted endpoints (Ollama, LM Studio). Every model block in your config is picked up automatically — all models appear in one list grouped by family, with no provider switching. - **Restart reminder** — a banner appears as soon as you change anything, reminding you to restart MiniMax Code (mini apps can't restart the host app for you). - **Search** across model IDs and display names. - **Instant save** — every toggle writes to your config immediately; no save button. diff --git a/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md b/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md index 67c971f..40e1af8 100644 --- a/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md +++ b/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md @@ -4,11 +4,11 @@ 一个 [MiniMax Code](https://github.com/MiniMax-AI) Mini App,用于浏览、搜索并启用/停用 `~/.minimax/config.yaml` 中的模型 —— 不用再在记事本里查找替换了。 -作者:[ocoomber](https://github.com/ocoomber) · 版本:`1.2.1` +作者:[ocoomber](https://github.com/ocoomber) · 版本:`1.2.2` ## 功能 -- **支持任意提供商** —— OpenRouter、自定义提供商,以及本地服务(Ollama、LM Studio)。配置里有多个提供商时会出现下拉框用于切换;只有一个时自动隐藏,不占地方。 +- **支持任意提供商** —— OpenRouter、自定义提供商,以及本地服务(Ollama、LM Studio)。自动读取配置中的所有模型块 —— 全部模型合并为一个列表,按模型家族分组,无需任何切换操作。 - **重启提醒** —— 一旦有任何改动,页面会出现醒目的横幅,提醒你需要重启 MiniMax Code(Mini App 无法代替你重启宿主程序)。 - **搜索** —— 同时匹配模型 ID 和显示名称。 - **即时保存** —— 每次切换立即写入配置,没有保存按钮。 diff --git a/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html b/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html index 953b911..a431549 100644 --- a/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html +++ b/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html @@ -47,10 +47,6 @@ background: var(--mcode-bg); color: var(--mcode-text); } .toolbar input[type='search']:focus { outline: none; border-color: var(--mcode-accent); } - .toolbar select { - height: 36px; padding: 0 8px; border: 1px solid var(--mcode-border); border-radius: 8px; - background: var(--mcode-bg); color: var(--mcode-text); max-width: 260px; - } .count { color: var(--mcode-text-muted); font-size: 12px; white-space: nowrap; } button { height: 36px; padding: 0 14px; border-radius: 8px; cursor: pointer; @@ -167,7 +163,6 @@

Model Manager

- Loading… @@ -197,8 +192,6 @@

Model Manager

(function () { 'use strict'; var models = []; - var providerIndex = 0; - var isOpenRouter = false; var canUndo = false; var collapsedGroups = {}; var groupsCollapsed = false; @@ -208,7 +201,6 @@

Model Manager

var countEl = document.getElementById('count'); var statusEl = document.getElementById('status'); var scopeEl = document.getElementById('scope'); - var providerEl = document.getElementById('provider'); var undoBtn = document.getElementById('undo'); var restartBanner = document.getElementById('restartBanner'); var themeMq = window.matchMedia('(prefers-color-scheme: dark)'); @@ -295,7 +287,7 @@

Model Manager

meta.appendChild(badge); var link = null; - if (isOpenRouter) { + if (m._or) { link = document.createElement('a'); link.className = 'link'; link.href = 'https://openrouter.ai/' + m.id.replace(/^~/, '').split('/').map(encodeURIComponent).join('/'); @@ -374,7 +366,7 @@

Model Manager

sw.appendChild(input); sw.appendChild(slider); input.addEventListener('change', function () { - toggle(m.id, input.checked, input); + toggle(m, input.checked, input); }); row.appendChild(main); @@ -485,81 +477,63 @@

Model Manager

}); } - function loadProviders(keepSelection) { + function load() { + setStatus('Loading…'); return fetch('/api/providers') .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (data) { - providerEl.textContent = ''; if (!data.providers.length) { - var opt = document.createElement('option'); - opt.textContent = 'No providers found'; - providerEl.appendChild(opt); - providerEl.hidden = true; + models = []; setStatus('No configurable models found in config.yaml.', true); - return []; + return null; } - data.providers.forEach(function (p) { - var opt = document.createElement('option'); - opt.value = String(p.index); - opt.textContent = p.label + ' (' + p.modelCount + ')'; - providerEl.appendChild(opt); + return Promise.all(data.providers.map(function (p, i) { + return fetch('/api/models?provider=' + i) + .then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }) + .then(function (d) { + d.models.forEach(function (m) { m._p = i; m._or = !!d.isOpenRouter; }); + return d.models; + }); + })).then(function (lists) { + models = [].concat.apply([], lists); + setUndo(false); // fresh read; undo applies to previous session edits only + setStatus(''); + render(); + return models; }); - providerEl.hidden = data.providers.length < 2; - if (keepSelection && providerIndex < data.providers.length) { - providerEl.value = String(providerIndex); - } else { - providerIndex = parseInt(providerEl.value, 10) || 0; - } - return data.providers; - }); - } - - function load() { - setStatus('Loading…'); - return loadProviders(true).then(function (providers) { - if (!providers.length) return null; - return fetch('/api/models?provider=' + providerIndex).then(function (r) { - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.json(); + }) + .catch(function (e) { + setStatus('Failed to load config: ' + e.message, true); }); - }).then(function (data) { - if (data === null) return; // no providers: loadProviders already explained why - models = data.models; - isOpenRouter = !!data.isOpenRouter; - setUndo(false); // fresh read; undo applies to previous session edits only - setStatus(''); - render(); - }).catch(function (e) { - setStatus('Failed to load config: ' + e.message, true); - }); } - function toggle(id, enabled, inputEl) { + function toggle(m, enabled, inputEl) { setStatus('Saving…'); fetch('/api/set', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ provider: providerIndex, model: id, enabled: enabled }) + body: JSON.stringify({ provider: m._p, model: m.id, enabled: enabled }) }) .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (d) { - var m = models.find(function (x) { return x.id === id; }); - if (m) m.enabled = enabled; + m.enabled = enabled; setUndo(true); if (d.changed) setRestartPending(true); - setStatus('Saved ' + id + ' → ' + (enabled ? 'enabled' : 'disabled') + (d.changed ? '' : ' (no change)')); + setStatus('Saved ' + m.id + ' → ' + (enabled ? 'enabled' : 'disabled') + (d.changed ? '' : ' (no change)')); render(); }) .catch(function (e) { if (inputEl) inputEl.checked = !enabled; - var m = models.find(function (x) { return x.id === id; }); - if (m) m.enabled = !enabled; + m.enabled = !enabled; setStatus('Save failed: ' + e.message, true); render(); }); @@ -572,20 +546,27 @@

Model Manager

return; } setStatus((enabled ? 'Enabling ' : 'Disabling ') + target.length + ' models…'); - fetch('/api/bulk', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ provider: providerIndex, enabled: enabled, models: target.map(function (m) { return m.id; }) }) - }) - .then(function (r) { + var byProvider = {}; + target.forEach(function (m) { + (byProvider[m._p] = byProvider[m._p] || []).push(m.id); + }); + var calls = Object.keys(byProvider).map(function (p) { + return fetch('/api/bulk', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ provider: parseInt(p, 10), enabled: enabled, models: byProvider[p] }) + }).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); - }) - .then(function (data) { + }); + }); + Promise.all(calls) + .then(function (results) { + var changedCount = results.reduce(function (sum, d) { return sum + (d.changedCount || 0); }, 0); target.forEach(function (m) { m.enabled = enabled; }); - setUndo(data.changedCount > 0); - if (data.changedCount > 0) setRestartPending(true); - setStatus('Updated ' + data.changedCount + ' models. Use Undo to revert.'); + setUndo(changedCount > 0); + if (changedCount > 0) setRestartPending(true); + setStatus('Updated ' + changedCount + ' models. Use Undo to revert.'); render(); }) .catch(function (e) { @@ -636,13 +617,6 @@

Model Manager

document.getElementById('chipAll').addEventListener('click', function () { setChip('all'); }); document.getElementById('chipEnabled').addEventListener('click', function () { setChip('enabled'); }); document.getElementById('chipDisabled').addEventListener('click', function () { setChip('disabled'); }); - providerEl.addEventListener('change', function () { - providerIndex = parseInt(providerEl.value, 10) || 0; - collapsedGroups = {}; - groupsCollapsed = false; - document.getElementById('toggleGroups').textContent = 'Collapse all'; - load(); - }); searchEl.addEventListener('input', render); load(); })(); From db10720dd1b639b34a6ce18a67933cb125eb47c0 Mon Sep 17 00:00:00 2001 From: ocoomber Date: Mon, 21 Sep 2026 16:30:31 +0100 Subject: [PATCH 3/5] Address PR review: harden config writes, add tests and disclosures (v1.2.3) server.mjs: - resolve config.yaml from the runtime dataDir first, ~/.minimax fallback - preserve each line's own terminator (CRLF/LF/CR mixed files stay intact) - atomic write keeps the original file mode; temp file removed on failure - serialize mutations behind a promise queue so overlapping requests cannot clobber each other or the undo snapshot - one-level undo now refuses (stale) if the file changed outside the app since the snapshot - backups moved next to config.yaml (backups/), pruned to newest 20 - 500 responses no longer leak raw error text; configPath removed from /api/providers client (index.html): - Undo enabled only when something actually changed; stale-undo shows a reload hint - group headers are real buttons with aria-controls (no nested interactive elements) - context menu closes on Escape and returns focus to the link - inline SVG icons (no emoji), no infinite pulse animation, warning colors from the visual-baseline tokens repo: - add tests/parser.test.mjs (node --test) covering EOL round-trip, provider discovery, colon ids, enabled flags - README/README.zh-CN: tested environment (Windows 11, MiniMax Code 3.0.73), macOS/Linux untested, process-spawning + temp-file + backups disclosures, corrected secrets wording, plugin-ID note - plugin.json: v1.2.3 + exampleQueries --- .../.minimax-plugin/plugin.json | 4 +- .../openrouter-model-manager/README.md | 25 ++- .../openrouter-model-manager/README.zh-CN.md | 25 ++- .../miniapp/client/index.html | 111 ++++++----- .../miniapp/node/server.mjs | 179 +++++++++++++----- .../tests/parser.test.mjs | 87 +++++++++ 6 files changed, 321 insertions(+), 110 deletions(-) create mode 100644 plugins/ocoomber/openrouter-model-manager/tests/parser.test.mjs diff --git a/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json b/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json index 61f4d62..acf3454 100644 --- a/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json +++ b/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json @@ -2,12 +2,12 @@ "schemaVersion": 1, "name": "openrouter-model-manager", "displayName": "Model Manager", - "version": "1.2.2", + "version": "1.2.3", "description": "Browse, search, and enable/disable the models in your MiniMax Code config.yaml — works with any provider (OpenRouter, custom, locally hosted), with bulk actions, one-level undo, and OpenRouter links.", "author": "ocoomber", "icon": "icon.png", "category": "Other", - "exampleQueries": [], + "exampleQueries": ["Open Model Manager", "打开模型管理器"], "apps": [], "mcpServers": [], "skills": [] diff --git a/plugins/ocoomber/openrouter-model-manager/README.md b/plugins/ocoomber/openrouter-model-manager/README.md index acc3bdd..f91cf92 100644 --- a/plugins/ocoomber/openrouter-model-manager/README.md +++ b/plugins/ocoomber/openrouter-model-manager/README.md @@ -2,9 +2,11 @@ English | [简体中文](README.zh-CN.md) -A [MiniMax Code](https://github.com/MiniMax-AI) Mini App for browsing, searching, and enabling/disabling the models in your `~/.minimax/config.yaml` — no more find-and-replace in Notepad. +A [MiniMax Code](https://github.com/MiniMax-AI) Mini App for browsing, searching, and enabling/disabling the models in your MiniMax Code `config.yaml` — no more find-and-replace in Notepad. -Author: [ocoomber](https://github.com/ocoomber) · Version: `1.2.2` +Author: [ocoomber](https://github.com/ocoomber) · Version: `1.2.3` + +> The plugin ID `openrouter-model-manager` is kept for stability, but the app is **not** OpenRouter-specific — it works with any provider (see *What it does*). ## What it does @@ -14,12 +16,17 @@ Author: [ocoomber](https://github.com/ocoomber) · Version: `1.2.2` - **Instant save** — every toggle writes to your config immediately; no save button. - **Filter chips** — All / Enabled only / Disabled only. - **Bulk actions** — *Enable matching* / *Disable matching* apply only to the current search results, and each model family has its own enable/disable buttons. -- **One-level Undo** — made a mistake with "enable all"? One click restores the previous config. -- **Automatic backups** — before every bulk change, a timestamped copy of your config is written to the plugin's data folder (`backups/`). +- **One-level Undo** — made a mistake with "enable all"? One click restores the previous config. If the file changed outside the app in the meantime, Undo refuses instead of clobbering your edits. +- **Automatic backups** — before every bulk change, a timestamped copy of your config is written to a `backups/` folder next to `config.yaml`, pruned to the newest 20. - **Collapsible families** — models are grouped by the prefix before the `/` in their ID. - **OpenRouter links** — every model row can link to its OpenRouter page (shown only for OpenRouter providers). Right-click a link to choose the external browser, the built-in browser, or copy the URL. - **Context-limit badges** — read straight from your config. +## Tested environment + +- **Windows 11** (build 10.0.26200), **MiniMax Code 3.0.73**, plugin `1.2.3` — tested by the author end to end (toggles, bulk actions, undo, restart flow). +- **macOS / Linux** use the same code paths but have **not been tested** by the author — feedback and reports are very welcome. + ## Install Copy (or clone) this folder into your MiniMax Code plugins directory: @@ -34,13 +41,16 @@ Then restart MiniMax Code and open the **Model Manager** mini app. ## How it works -The Node runtime reads `~/.minimax/config.yaml` line by line (no YAML library) and finds every `models:` block that has `enabled:` flags. Toggling a model rewrites only that model's `enabled:` line. All writes are atomic (temp file + rename), and your file's existing indentation and line endings are preserved. +The Node runtime reads your `config.yaml` line by line (no YAML library) and finds every `models:` block that has `enabled:` flags. Toggling a model rewrites only that model's `enabled:` line. All writes are atomic (a temp file `.config.yaml.mm-tmp` is written next to your config, then renamed; it is removed if anything fails), your file's existing indentation and line endings are preserved, and concurrent edits are serialized so overlapping clicks can't clobber each other. Capabilities such as vision support are intentionally **not** fetched from external APIs — your config file is the single source of truth. -## Privacy +## Privacy & data safety -The app reads and writes only your `~/.minimax/config.yaml` (backups go to the plugin's own data folder). It makes no network requests and never displays API keys — secrets stay hidden in the UI. +- The UI never sees your secrets: the server returns only model **id / name / enabled / contextLimit**. API keys in the config are never read into the UI, returned by the API, or displayed. +- The app makes **no outbound network requests** of its own. +- **Process spawning (disclosed):** the only OS-level action is opening an OpenRouter model page in *your own* browser, via `rundll32`/`cmd`/`explorer` on Windows, `open` on macOS, or `xdg-open` on Linux. Only `https://openrouter.ai/...` URLs are accepted; anything else is rejected by the server. +- Config location: the runtime resolves `config.yaml` from its data directory first and falls back to the default `~/.minimax/config.yaml`. ## Files @@ -52,6 +62,7 @@ miniapp/client/index.html UI (light/dark aware) miniapp/node/server.mjs Node runtime + REST API miniapp/node/miniapp-api.ts Type declarations for the runtime API icon.png Plugin icon +tests/parser.test.mjs Parser tests (repository only — run with `node --test tests/parser.test.mjs`, not shipped in the install payload) ``` ## License diff --git a/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md b/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md index 40e1af8..b4d0007 100644 --- a/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md +++ b/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md @@ -2,9 +2,11 @@ [English](README.md) | 简体中文 -一个 [MiniMax Code](https://github.com/MiniMax-AI) Mini App,用于浏览、搜索并启用/停用 `~/.minimax/config.yaml` 中的模型 —— 不用再在记事本里查找替换了。 +一个 [MiniMax Code](https://github.com/MiniMax-AI) Mini App,用于浏览、搜索并启用/停用 MiniMax Code `config.yaml` 中的模型 —— 不用再在记事本里查找替换了。 -作者:[ocoomber](https://github.com/ocoomber) · 版本:`1.2.2` +作者:[ocoomber](https://github.com/ocoomber) · 版本:`1.2.3` + +> 插件 ID `openrouter-model-manager` 为保持稳定而保留,但本应用**并非** OpenRouter 专用 —— 支持任意提供商(见"功能")。 ## 功能 @@ -14,12 +16,17 @@ - **即时保存** —— 每次切换立即写入配置,没有保存按钮。 - **筛选** —— 全部 / 仅启用 / 仅停用。 - **批量操作** —— "启用匹配项 / 停用匹配项"只作用于当前搜索结果;每个模型家族也有自己的启用/停用按钮。 -- **一步撤销** —— 批量开启后后悔了?点一下即可恢复上一个配置。 -- **自动备份** —— 每次批量改动前,都会在插件数据目录的 `backups/` 里保存一份带时间戳的配置副本。 +- **一步撤销** —— 批量开启后后悔了?点一下即可恢复上一个配置。若期间配置在应用之外被修改过,撤销会拒绝执行,而不会覆盖你的改动。 +- **自动备份** —— 每次批量改动前,都会在 `config.yaml` 旁边的 `backups/` 目录里保存一份带时间戳的副本,最多保留最新 20 份。 - **可折叠的模型家族** —— 模型按 ID 中 `/` 之前的前缀分组;家族内有已启用的模型时,折叠状态会显示绿点。 - **OpenRouter 链接** —— 每个模型行可跳转到 OpenRouter 页面(仅 OpenRouter 提供商显示)。右键链接可选择外部浏览器、内置浏览器或复制网址。 - **上下文长度徽标** —— 直接读取自你的配置。 +## 测试环境 + +- **Windows 11**(build 10.0.26200)、**MiniMax Code 3.0.73**、插件 `1.2.3` —— 作者已完成端到端实测(切换、批量操作、撤销、重启生效流程)。 +- **macOS / Linux** 走相同代码路径,但**未经作者实测** —— 欢迎反馈问题。 + ## 安装 将本目录完整复制(或克隆)到 MiniMax Code 的插件目录: @@ -34,13 +41,16 @@ ## 工作原理 -Node 运行时逐行读取 `~/.minimax/config.yaml`(不依赖 YAML 库),找出所有带 `enabled:` 开关的 `models:` 块。切换某个模型时只改写该模型的 `enabled:` 一行。所有写入都是原子性的(先写临时文件再重命名),并完整保留你文件原有的缩进和换行符。模型 ID 解析兼容 `llama3.1:latest`、`:free` 这类带冒号的写法。 +Node 运行时逐行读取 `config.yaml`(不依赖 YAML 库),找出所有带 `enabled:` 开关的 `models:` 块。切换某个模型时只改写该模型的 `enabled:` 一行。所有写入都是原子性的(先在你的配置文件旁边写临时文件 `.config.yaml.mm-tmp` 再重命名,失败时会删除临时文件),完整保留你文件原有的缩进和换行符,并且并发修改会被串行化,多次快速点击不会互相覆盖。模型 ID 解析兼容 `llama3.1:latest`、`:free` 这类带冒号的写法。 视觉支持等模型能力刻意不从外部 API 获取 —— 配置文件是唯一数据来源。 -## 隐私 +## 隐私与数据安全 -应用只读写你的 `~/.minimax/config.yaml`(备份保存在插件自己的数据目录)。不发起任何网络请求,也不会显示 API Key —— 密钥在界面中始终隐藏。 +- 界面永远接触不到你的密钥:服务端只返回模型的 **id / name / enabled / contextLimit**。配置中的 API Key 不会被读入界面、不会通过 API 返回、也不会显示。 +- 应用自身**不发起任何网络请求**。 +- **进程启动(主动披露)**:唯一涉及操作系统层面的动作,是用你自己的浏览器打开 OpenRouter 模型页面 —— Windows 上通过 `rundll32`/`cmd`/`explorer`,macOS 上 `open`,Linux 上 `xdg-open`。仅接受 `https://openrouter.ai/...` 格式的网址,其余一律被服务端拒绝。 +- 配置位置:运行时优先从其数据目录解析 `config.yaml`,找不到时回退到默认的 `~/.minimax/config.yaml`。 ## 文件结构 @@ -52,6 +62,7 @@ miniapp/client/index.html 界面(自动适配亮/暗色) miniapp/node/server.mjs Node 运行时 + REST API miniapp/node/miniapp-api.ts 运行时 API 类型声明 icon.png 插件图标 +tests/parser.test.mjs 解析器测试(仅仓库内 —— 用 `node --test tests/parser.test.mjs` 运行,不随安装包发布) ``` ## 许可证 diff --git a/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html b/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html index a431549..b3c9332 100644 --- a/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html +++ b/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html @@ -10,7 +10,7 @@ --mcode-surface-muted: #fafafa; --mcode-text: #171717; --mcode-text-muted: #666666; --mcode-text-subtle: #adadad; --mcode-border: #0a0a0a14; --mcode-border-strong: #0a0a0af2; --mcode-accent: #0094fc; --mcode-success: #04b54b; --mcode-danger: #f73646; - --mcode-warning: #b45309; --mcode-warning-bg: #fef3c7; + --mcode-warning: #f56811; --mcode-primary-action-bg: #171717; --mcode-primary-action-text: #ffffff; } [data-theme='dark'] { @@ -18,7 +18,7 @@ --mcode-surface-muted: #262626; --mcode-text: #ededed; --mcode-text-muted: #949494; --mcode-text-subtle: #666666; --mcode-border: #ffffff14; --mcode-border-strong: #fffffff2; --mcode-accent: #0077d9; --mcode-success: #009c3d; --mcode-danger: #e31937; - --mcode-warning: #fbbf24; --mcode-warning-bg: #3a2e12; + --mcode-warning: #e25507; --mcode-primary-action-bg: #ffffff; --mcode-primary-action-text: #171717; } * { box-sizing: border-box; } @@ -32,9 +32,11 @@ .desc { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 0; padding: 6px 12px; border-radius: 999px; - background: var(--mcode-warning-bg); color: var(--mcode-warning); - font-weight: 600; font-size: 13px; + background: var(--mcode-surface-muted); color: var(--mcode-text); + border: 1px solid var(--mcode-warning); + font-size: 13px; } + .desc svg { color: var(--mcode-warning); flex: none; } .topbar { position: sticky; top: 0; z-index: 5; background: var(--mcode-bg); } .toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; @@ -64,12 +66,11 @@ .banner { display: flex; align-items: center; gap: 10px; margin: 0 0 12px; padding: 10px 14px; border-radius: 10px; - background: var(--mcode-warning-bg); border: 1px solid var(--mcode-warning); - color: var(--mcode-warning); font-size: 13px; font-weight: 600; + background: var(--mcode-surface-muted); border: 1px solid var(--mcode-warning); + color: var(--mcode-text); font-size: 13px; font-weight: 600; } - .banner .bicon { font-size: 16px; line-height: 1; animation: banner-pulse 1.6s ease-in-out infinite; } + .banner .bicon { color: var(--mcode-warning); flex: none; line-height: 0; } .banner[hidden] { display: none; } - @keyframes banner-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } } .chip { height: 28px; padding: 0 12px; border-radius: 999px; font-size: 13px; border: 1px solid var(--mcode-border); background: var(--mcode-surface); @@ -90,13 +91,18 @@ .ghead { display: flex; flex-wrap: wrap; align-items: center; column-gap: 10px; row-gap: 6px; padding: 8px 16px; - background: var(--mcode-surface-muted); min-height: 40px; cursor: pointer; user-select: none; - border: none; width: 100%; text-align: left; border-radius: 0; + background: var(--mcode-surface-muted); min-height: 40px; } .ghead:hover { background: var(--mcode-bg-secondary); } - .ghead:focus-visible { outline: 2px solid var(--mcode-accent); outline-offset: -2px; } - .ghead .caret { flex: none; transition: transform 200ms ease-out; color: var(--mcode-text-subtle); } - .ghead[aria-expanded='false'] .caret { transform: rotate(-90deg); } + .ghead-btn { + display: flex; flex-wrap: wrap; align-items: center; column-gap: 10px; row-gap: 6px; + flex: 1 1 0; min-width: 0; padding: 0; border: none; background: none; + color: inherit; font: inherit; text-align: left; cursor: pointer; user-select: none; + height: 100%; + } + .ghead-btn:focus-visible { outline: 2px solid var(--mcode-accent); outline-offset: 2px; } + .ghead-btn .caret { flex: none; transition: transform 200ms ease-out; color: var(--mcode-text-subtle); } + .ghead-btn[aria-expanded='false'] .caret { transform: rotate(-90deg); } .gdot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--mcode-success); opacity: 0; @@ -113,7 +119,7 @@ .gactions { display: flex; gap: 8px; flex: 0 1 240px; } .gactions button { flex: 1 1 auto; } .rows { border-top: 1px solid var(--mcode-border); } - .ghead[aria-expanded='false'] + .rows { display: none; } + .group.collapsed .rows { display: none; } .row { display: flex; align-items: center; gap: 12px; padding: 8px 16px; border-bottom: 1px solid var(--mcode-border); min-height: 40px; @@ -159,7 +165,7 @@

Model Manager

-

🔄 Changes here don't apply until you restart MiniMax Code

+

Changes here don't apply until you restart MiniMax Code

@@ -178,7 +184,7 @@

Model Manager

@@ -320,7 +326,7 @@

Model Manager

e.preventDefault(); var menu = document.createElement('div'); menu.setAttribute('role', 'menu'); - menu.style.cssText = 'position:fixed;z-index:100;background:var(--mcode-surface);border:1px solid var(--mcode-border);border-radius:8px;box-shadow:0 0 20px rgb(10 10 10 / 12%);padding:4px;min-width:180px;'; + menu.style.cssText = 'position:fixed;z-index:100;background:var(--mcode-surface);border:1px solid var(--mcode-border);border-radius:12px;box-shadow:0 0 20px rgb(10 10 10 / 8%);padding:4px;min-width:180px;'; function item(label, fn) { var b = document.createElement('button'); b.type = 'button'; @@ -332,9 +338,18 @@

Model Manager

b.addEventListener('click', function () { cleanup(); fn(); }); return b; } - function cleanup() { + function cleanup(refocus) { document.removeEventListener('click', onDocClick, true); + document.removeEventListener('keydown', onKeydown, true); menu.remove(); + if (refocus) link.focus(); + } + function onKeydown(e) { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + cleanup(true); + } } function onDocClick() { cleanup(); } menu.appendChild(item('Open in external browser', openExternal)); @@ -351,7 +366,12 @@

Model Manager

document.body.appendChild(menu); menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px'; menu.style.top = Math.min(e.clientY, window.innerHeight - 130) + 'px'; - setTimeout(function () { document.addEventListener('click', onDocClick, true); }, 0); + setTimeout(function () { + document.addEventListener('click', onDocClick, true); + document.addEventListener('keydown', onKeydown, true); + }, 0); + var firstItem = menu.querySelector('[role="menuitem"]'); + if (firstItem) firstItem.focus(); }); } @@ -396,18 +416,21 @@

Model Manager

groupModels(filtered).forEach(function (g) { var sec = document.createElement('div'); - sec.className = 'group'; + sec.className = 'group' + (collapsedGroups[g.family] ? ' collapsed' : ''); var head = document.createElement('div'); head.className = 'ghead'; - head.setAttribute('role', 'button'); - head.tabIndex = 0; - head.setAttribute('aria-expanded', collapsedGroups[g.family] ? 'false' : 'true'); + + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'ghead-btn'; + btn.setAttribute('aria-expanded', collapsedGroups[g.family] ? 'false' : 'true'); + btn.setAttribute('aria-controls', 'rows-' + g.family); var caret = document.createElement('span'); caret.className = 'caret'; - caret.textContent = '▾'; caret.setAttribute('aria-hidden', 'true'); + caret.innerHTML = ''; var nm = document.createElement('span'); nm.className = 'gname'; @@ -428,20 +451,14 @@

Model Manager

bEn.className = 'mini'; bEn.textContent = 'Enable group'; bEn.setAttribute('aria-label', 'Enable all ' + g.family + ' models'); - bEn.addEventListener('click', function (e) { - e.stopPropagation(); - bulk(true, g.models); - }); + bEn.addEventListener('click', function () { bulk(true, g.models); }); var bDis = document.createElement('button'); bDis.type = 'button'; bDis.className = 'mini danger'; bDis.textContent = 'Disable group'; bDis.setAttribute('aria-label', 'Disable all ' + g.family + ' models'); - bDis.addEventListener('click', function (e) { - e.stopPropagation(); - bulk(false, g.models); - }); + bDis.addEventListener('click', function () { bulk(false, g.models); }); var actions = document.createElement('span'); actions.className = 'gactions'; @@ -450,25 +467,21 @@

Model Manager

function toggleGroup() { collapsedGroups[g.family] = !collapsedGroups[g.family]; - head.setAttribute('aria-expanded', collapsedGroups[g.family] ? 'false' : 'true'); + btn.setAttribute('aria-expanded', collapsedGroups[g.family] ? 'false' : 'true'); + sec.classList.toggle('collapsed', collapsedGroups[g.family]); dot.classList.toggle('on', collapsedGroups[g.family] && enCount > 0); } - head.appendChild(dot); - head.appendChild(caret); - head.appendChild(nm); - head.appendChild(gc); + btn.appendChild(dot); + btn.appendChild(caret); + btn.appendChild(nm); + btn.appendChild(gc); + btn.addEventListener('click', toggleGroup); + head.appendChild(btn); head.appendChild(actions); - head.addEventListener('click', toggleGroup); - head.addEventListener('keydown', function (e) { - if (e.target !== head) return; - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleGroup(); - } - }); var rows = document.createElement('div'); rows.className = 'rows'; + rows.id = 'rows-' + g.family; g.models.forEach(function (m) { rows.appendChild(makeRow(m)); }); sec.appendChild(head); @@ -526,7 +539,7 @@

Model Manager

}) .then(function (d) { m.enabled = enabled; - setUndo(true); + setUndo(!!d.changed); if (d.changed) setRestartPending(true); setStatus('Saved ' + m.id + ' → ' + (enabled ? 'enabled' : 'disabled') + (d.changed ? '' : ' (no change)')); render(); @@ -588,7 +601,11 @@

Model Manager

return load().then(function () { setStatus('Last change reverted.'); }); } setUndo(false); - setStatus('Nothing to undo.'); + if (d.stale) { + setStatus('The config file changed outside the app since your last change. Reload to refresh.', true); + } else { + setStatus('Nothing to undo.'); + } }) .catch(function (e) { setStatus('Undo failed: ' + e.message, true); diff --git a/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs b/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs index f0d7882..dc6e657 100644 --- a/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs +++ b/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs @@ -3,7 +3,8 @@ // Discovers any provider block containing discovered models (OpenRouter, // custom providers, locally hosted endpoints such as Ollama/LM Studio). -import { readFile, writeFile, rename, mkdir } from 'node:fs/promises'; +import { readFile, writeFile, rename, mkdir, readdir, unlink, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; import { createServer } from 'node:http'; import { join, dirname } from 'node:path'; import { homedir, platform } from 'node:os'; @@ -12,24 +13,78 @@ import { spawn } from 'node:child_process'; /** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ /** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ -const CONFIG_PATH = join(homedir(), '.minimax', 'config.yaml'); +/** Absolute path of the config file, resolved once at startup. */ +let CONFIG_PATH = join(homedir(), '.minimax', 'config.yaml'); + +/** + * Resolve config.yaml from the runtime-provided data directory first so that + * non-default data directories / profiles work, falling back to the default + * ~/.minimax location. + */ +function resolveConfigPath(dataDir) { + const candidates = []; + if (dataDir) candidates.push(join(dataDir, 'config.yaml')); + candidates.push(join(homedir(), '.minimax', 'config.yaml')); + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + return candidates[0]; +} async function readConfigText() { return readFile(CONFIG_PATH, 'utf8'); } -/** Split config text into lines, remembering the dominant EOL so writes preserve it. */ +/** + * Split config text into lines, remembering each line's own terminator + * (CRLF, LF, or CR) so writes preserve line endings exactly — including + * files that mix them. The final entry has an empty terminator. + */ export function splitConfigText(text) { - return { - lines: text.split(/\r?\n/), - eol: text.includes('\r\n') ? '\r\n' : '\n', - }; + const lines = []; + const terms = []; + let start = 0; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (ch !== '\n' && ch !== '\r') { i++; continue; } + let term = ch; + i++; + if (term === '\r' && text[i] === '\n') { term = '\r\n'; i++; } + lines.push(text.slice(start, i - term.length)); + terms.push(term); + start = i; + } + lines.push(text.slice(start)); + terms.push(''); + return { lines, terms }; +} + +/** Re-join parsed lines with their original terminators. */ +export function joinConfigText({ lines, terms }) { + return lines.map((line, i) => line + (terms[i] ?? '')).join(''); } -async function writeConfigLines(lines, eol) { +/** Atomic write: preserve the original file mode, remove the temp file on failure. */ +async function writeConfigText(parsed) { const tmp = join(dirname(CONFIG_PATH), '.config.yaml.mm-tmp'); - await writeFile(tmp, lines.join(eol), 'utf8'); - await rename(tmp, CONFIG_PATH); + let mode; + try { + mode = (await stat(CONFIG_PATH)).mode & 0o777; + } catch { + // config.yaml may not exist yet; use the default creation mode + } + try { + if (mode === undefined) { + await writeFile(tmp, joinConfigText(parsed), 'utf8'); + } else { + await writeFile(tmp, joinConfigText(parsed), { encoding: 'utf8', mode }); + } + await rename(tmp, CONFIG_PATH); + } catch (error) { + try { await unlink(tmp); } catch { /* already gone */ } + throw error; + } } /** @@ -111,71 +166,97 @@ function findProvider(providers, index) { return providers[index]; } -/** One-level undo: raw config text taken before the most recent mutation. */ +/** One-level undo: config texts around the most recent mutation. + * `prev` is the file before the mutation, `after` after it — used to + * detect edits made outside the app since the snapshot. */ let lastSnapshot = null; -async function takeSnapshot(text, withBackup, dataDir) { - lastSnapshot = text; - if (withBackup && dataDir) { - try { - const backupDir = join(dataDir, 'backups'); - await mkdir(backupDir, { recursive: true }); - const stamp = new Date().toISOString().replace(/[:.]/g, '-'); - await writeFile(join(backupDir, `config-${stamp}.yaml`), text, 'utf8'); - } catch { - // backup is best-effort; never block the mutation +/** Keep only the newest MAX_BACKUPS backups. */ +const MAX_BACKUPS = 20; + +async function takeSnapshot(text, withBackup) { + lastSnapshot = { prev: text, after: null }; + if (!withBackup) return; + try { + const backupDir = join(dirname(CONFIG_PATH), 'backups'); + await mkdir(backupDir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + await writeFile(join(backupDir, `config-${stamp}.yaml`), text, 'utf8'); + const entries = (await readdir(backupDir)).filter((f) => /^config-.*\.yaml$/.test(f)).sort(); + const stale = entries.slice(0, Math.max(0, entries.length - MAX_BACKUPS)); + for (const name of stale) { + try { await unlink(join(backupDir, name)); } catch { /* best effort */ } } + } catch { + // backup is best-effort; never block the mutation } } +/** Serialize every config mutation: each one is a full-file read → modify → + * write, so parallel requests must not interleave or they would clobber + * each other's changes (and the undo snapshot). */ +let mutationTail = Promise.resolve(); +function withMutationLock(task) { + const run = mutationTail.then(task, task); + mutationTail = run.then(() => undefined, () => undefined); + return run; +} + async function undo() { if (lastSnapshot === null) return { undone: false }; - const tmp = join(dirname(CONFIG_PATH), '.config.yaml.mm-tmp'); - await writeFile(tmp, lastSnapshot, 'utf8'); - await rename(tmp, CONFIG_PATH); + const current = await readConfigText(); + if (lastSnapshot.after !== null && current !== lastSnapshot.after) { + // The file changed outside the app since the snapshot; refuse rather than clobber it. + return { undone: false, stale: true }; + } + await writeConfigText({ lines: [lastSnapshot.prev], terms: [''] }); lastSnapshot = null; return { undone: true }; } -function currentEnabled(line) { +export function currentEnabled(line) { const m = line.match(/enabled:\s*(true|false)/); return m ? m[1] : null; } -function setEnabledOnLine(line, enabled) { +export function setEnabledOnLine(line, enabled) { return line.replace(/(enabled:\s*)(true|false)/, `$1${enabled}`); } async function setModelEnabled(providerIndex, modelId, enabled) { const text = await readConfigText(); - const { lines, eol } = splitConfigText(text); - const provider = findProvider(parseProviders(lines), providerIndex); + const parsed = splitConfigText(text); + const provider = findProvider(parseProviders(parsed.lines), providerIndex); if (!provider) throw new Error('Provider not found'); const model = provider.models.find((m) => m.id === modelId); if (!model) throw new Error(`Unknown model: ${modelId}`); - const line = lines[model.enabledIndex]; + const line = parsed.lines[model.enabledIndex]; if (currentEnabled(line) === String(enabled)) return { changed: false }; await takeSnapshot(text, false); - lines[model.enabledIndex] = setEnabledOnLine(line, enabled); - await writeConfigLines(lines, eol); + parsed.lines[model.enabledIndex] = setEnabledOnLine(line, enabled); + const written = joinConfigText(parsed); + await writeConfigText(parsed); + if (lastSnapshot) lastSnapshot.after = written; return { changed: true }; } -async function setModelsEnabled(providerIndex, ids, enabled, dataDir) { +async function setModelsEnabled(providerIndex, ids, enabled) { const text = await readConfigText(); - const { lines, eol } = splitConfigText(text); - const provider = findProvider(parseProviders(lines), providerIndex); + const parsed = splitConfigText(text); + const provider = findProvider(parseProviders(parsed.lines), providerIndex); if (!provider) throw new Error('Provider not found'); const wanted = new Set(ids); const touched = provider.models.filter( - (m) => wanted.has(m.id) && currentEnabled(lines[m.enabledIndex]) !== String(enabled), + (m) => wanted.has(m.id) && currentEnabled(parsed.lines[m.enabledIndex]) !== String(enabled), ); if (touched.length > 0) { - await takeSnapshot(text, true, dataDir); + await takeSnapshot(text, true); for (const m of touched) { - lines[m.enabledIndex] = setEnabledOnLine(lines[m.enabledIndex], enabled); + parsed.lines[m.enabledIndex] = setEnabledOnLine(parsed.lines[m.enabledIndex], enabled); } - await writeConfigLines(lines, eol); + const written = joinConfigText(parsed); + await writeConfigText(parsed); + if (lastSnapshot) lastSnapshot.after = written; } return touched.length; } @@ -250,6 +331,8 @@ function openExternal(url, logger) { } export async function start(context) { + CONFIG_PATH = resolveConfigPath(context.dataDir); + const clientIndex = await readFile( join(context.pluginRoot, 'miniapp/client/index.html'), 'utf8', @@ -271,7 +354,6 @@ export async function start(context) { const { lines } = splitConfigText(await readConfigText()); const providers = parseProviders(lines); json(response, 200, { - configPath: CONFIG_PATH, providers: providers.map((p, i) => ({ index: i, label: p.label, @@ -313,7 +395,9 @@ export async function start(context) { json(response, 400, { error: 'invalid_arguments' }); return; } - const result = await setModelEnabled(typeof provider === 'number' ? provider : 0, model, enabled); + const result = await withMutationLock(() => + setModelEnabled(typeof provider === 'number' ? provider : 0, model, enabled), + ); json(response, 200, result); return; } @@ -328,11 +412,12 @@ export async function start(context) { json(response, 400, { error: 'invalid_arguments' }); return; } - const changedCount = await setModelsEnabled( - typeof provider === 'number' ? provider : 0, - [...new Set(models)], - enabled, - context.dataDir, + const changedCount = await withMutationLock(() => + setModelsEnabled( + typeof provider === 'number' ? provider : 0, + [...new Set(models)], + enabled, + ), ); json(response, 200, { changedCount }); return; @@ -340,7 +425,7 @@ export async function start(context) { // One-level undo of the most recent mutation. if (request.method === 'POST' && url.pathname === '/api/undo') { - const result = await undo(); + const result = await withMutationLock(() => undo()); json(response, 200, result); return; } @@ -363,7 +448,7 @@ export async function start(context) { json(response, 404, { error: 'not_found' }); } catch (error) { context.logger.error('miniapp.request.failed', { route, message: String(error?.message ?? error) }); - json(response, 500, { error: 'internal_error', message: String(error?.message ?? error) }); + json(response, 500, { error: 'internal_error' }); } }); diff --git a/plugins/ocoomber/openrouter-model-manager/tests/parser.test.mjs b/plugins/ocoomber/openrouter-model-manager/tests/parser.test.mjs new file mode 100644 index 0000000..1c3728d --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/tests/parser.test.mjs @@ -0,0 +1,87 @@ +// Parser tests for the Model Manager mini app. +// These test the exported pure functions from miniapp/node/server.mjs — +// they never touch the real config.yaml or start the HTTP server. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + splitConfigText, + joinConfigText, + parseProviders, + currentEnabled, + setEnabledOnLine, +} from '../miniapp/node/server.mjs'; + +// Structure mirrors a real config: top-level `provider:` (built-in models, +// no `enabled:` flags) and a nested `custom_provider` block with toggleable +// models. Note the model id containing ':' — a common OpenRouter id form. +const SAMPLE = [ + 'provider:', + ' name: MiniMax', + ' models:', + ' abab6.5s-chat:', + ' name: abab6.5s-chat', + '', + 'custom_provider:', + ' openrouter:', + ' models:', + ' deepseek/deepseek-chat-v3.1:free:', + ' enabled: true', + ' name: DeepSeek V3.1 (free)', + ' context: 163840', + ' openai/gpt-4o:', + ' enabled: false', + ' name: GPT-4o', + ' context: 128000', + '# commented-out/model:', + ' meta-llama/llama-3.1-8b-instruct:', + ' name: Llama 3.1 8B', +].join('\n'); + +test('splitConfigText remembers each line terminator (CRLF/LF/CR mixed)', () => { + const text = 'a: 1\r\nb: 2\nc: 3\r# comment\nd: 4'; + const parsed = splitConfigText(text); + assert.deepEqual(parsed.lines, ['a: 1', 'b: 2', 'c: 3', '# comment', 'd: 4']); + assert.deepEqual(parsed.terms, ['\r\n', '\n', '\r', '\n', '']); + assert.equal(joinConfigText(parsed), text); +}); + +test('joinConfigText restores exact text including a trailing newline', () => { + const text = 'enabled: true\nenabled: false\n'; + const parsed = splitConfigText(text); + assert.deepEqual(parsed.terms, ['\n', '\n', '']); + assert.equal(joinConfigText(parsed), text); +}); + +test('parseProviders: toggles offered only for blocks with enabled: flags', () => { + const { lines } = splitConfigText(SAMPLE); + const providers = parseProviders(lines); + assert.equal(providers.length, 1); // `provider:` has no enabled flags → skipped + assert.equal(providers[0].label, 'custom_provider / openrouter'); + assert.deepEqual(providers[0].path, ['custom_provider', 'openrouter']); +}); + +test('parseProviders: keeps model ids that contain a colon, skips comments', () => { + const { lines } = splitConfigText(SAMPLE); + const providers = parseProviders(lines); + const ids = providers[0].models.map((m) => m.id); + assert.ok(ids.includes('deepseek/deepseek-chat-v3.1:free')); + assert.ok(!ids.includes('commented-out/model')); + assert.equal(ids.length, 2); // llama has no enabled: line → not toggleable +}); + +test('parseProviders: reports enabled state from the file', () => { + const { lines } = splitConfigText(SAMPLE); + const [provider] = parseProviders(lines); + const deepseek = provider.models.find((m) => m.id === 'deepseek/deepseek-chat-v3.1:free'); + const gpt4o = provider.models.find((m) => m.id === 'openai/gpt-4o'); + assert.equal(deepseek.enabled, true); + assert.equal(gpt4o.enabled, false); +}); + +test('currentEnabled reads the flag; setEnabledOnLine flips only that flag', () => { + const line = ' enabled: false # keep comment'; + assert.equal(currentEnabled(line), 'false'); + const next = setEnabledOnLine(line, true); + assert.equal(next, ' enabled: true # keep comment'); + assert.equal(currentEnabled(next), 'true'); +}); From ff05789f5b6aa369f8691171754fc9d2c012f309 Mon Sep 17 00:00:00 2001 From: ocoomber Date: Tue, 22 Sep 2026 00:21:13 +0100 Subject: [PATCH 4/5] Walk up to the data root; back up under dataDir; bulk undo reverts the whole action (v1.2.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server.mjs: - resolveConfigPath now walks ancestors of context.dataDir looking for config.yaml (matches the current Host layout where context.dataDir is several levels below the data root) and falls back to ~/.minimax/config.yaml; if neither is found it returns the default rather than a missing path - DATA_DIR captured at startup so backups land under the plugin-owned namespace (context.dataDir/backups), pruned to the newest 20 — matches the 'store durable state under dataDir' runtime guidance and avoids touching anything in the data-root namespace - /api/bulk now accepts {enabled, providers: [{provider, models}]} so a single bulk request takes one snapshot and one write; Undo reverts the whole action regardless of how many providers it touched client (index.html): - bulk() sends one multi-provider request to /api/bulk (instead of one Promise.all per provider); Undo enabled iff any model changed tests/: - parser.test.mjs: +1 test covering the multi-provider toggle invariant (ids remain valid across providers in a single write) - resolveConfigPath.test.mjs (new): 3 tests covering the four-ancestor walk, the default fallback, and a sibling-ancestor config READMEs (EN + zh): - config-location: rewrote to describe the parent walk accurately, called out that the walk matches an implementation detail of the current Host layout, and noted the default fallback is what protects non-default installs - backups: relocated disclosure to dataDir/backups (plugin-owned) - Undo: noted that multi-provider bulk is reverted as one operation - Tests/-folder wording: clarified it lives outside the Host's runtime payload roots (miniapp/client, miniapp/node) rather than being excluded from a directory copy - Tested env: bumped to v1.2.4 plugin.json: 1.2.4 --- .../.minimax-plugin/plugin.json | 2 +- .../openrouter-model-manager/README.md | 15 ++- .../openrouter-model-manager/README.zh-CN.md | 15 ++- .../miniapp/client/index.html | 23 ++-- .../miniapp/node/server.mjs | 104 ++++++++++++------ .../tests/parser.test.mjs | 22 ++++ .../tests/resolveConfigPath.test.mjs | 67 +++++++++++ 7 files changed, 189 insertions(+), 59 deletions(-) create mode 100644 plugins/ocoomber/openrouter-model-manager/tests/resolveConfigPath.test.mjs diff --git a/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json b/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json index acf3454..7182cbc 100644 --- a/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json +++ b/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "openrouter-model-manager", "displayName": "Model Manager", - "version": "1.2.3", + "version": "1.2.4", "description": "Browse, search, and enable/disable the models in your MiniMax Code config.yaml — works with any provider (OpenRouter, custom, locally hosted), with bulk actions, one-level undo, and OpenRouter links.", "author": "ocoomber", "icon": "icon.png", diff --git a/plugins/ocoomber/openrouter-model-manager/README.md b/plugins/ocoomber/openrouter-model-manager/README.md index f91cf92..94cfea3 100644 --- a/plugins/ocoomber/openrouter-model-manager/README.md +++ b/plugins/ocoomber/openrouter-model-manager/README.md @@ -4,7 +4,7 @@ English | [简体中文](README.zh-CN.md) A [MiniMax Code](https://github.com/MiniMax-AI) Mini App for browsing, searching, and enabling/disabling the models in your MiniMax Code `config.yaml` — no more find-and-replace in Notepad. -Author: [ocoomber](https://github.com/ocoomber) · Version: `1.2.3` +Author: [ocoomber](https://github.com/ocoomber) · Version: `1.2.4` > The plugin ID `openrouter-model-manager` is kept for stability, but the app is **not** OpenRouter-specific — it works with any provider (see *What it does*). @@ -16,15 +16,15 @@ Author: [ocoomber](https://github.com/ocoomber) · Version: `1.2.3` - **Instant save** — every toggle writes to your config immediately; no save button. - **Filter chips** — All / Enabled only / Disabled only. - **Bulk actions** — *Enable matching* / *Disable matching* apply only to the current search results, and each model family has its own enable/disable buttons. -- **One-level Undo** — made a mistake with "enable all"? One click restores the previous config. If the file changed outside the app in the meantime, Undo refuses instead of clobbering your edits. -- **Automatic backups** — before every bulk change, a timestamped copy of your config is written to a `backups/` folder next to `config.yaml`, pruned to the newest 20. +- **One-level Undo** — made a mistake with "enable all"? One click restores the previous config. If the file changed outside the app in the meantime, Undo refuses instead of clobbering your edits. A multi-provider bulk action is reverted as one operation — not provider-by-provider. +- **Automatic backups** — before every bulk change, a timestamped copy of your config is written to `backups/` under the Mini App's own data directory (per Mini App runtime guidance), pruned to the newest 20. - **Collapsible families** — models are grouped by the prefix before the `/` in their ID. - **OpenRouter links** — every model row can link to its OpenRouter page (shown only for OpenRouter providers). Right-click a link to choose the external browser, the built-in browser, or copy the URL. - **Context-limit badges** — read straight from your config. ## Tested environment -- **Windows 11** (build 10.0.26200), **MiniMax Code 3.0.73**, plugin `1.2.3` — tested by the author end to end (toggles, bulk actions, undo, restart flow). +- **Windows 11** (build 10.0.26200), **MiniMax Code 3.0.73**, plugin `1.2.4` — tested by the author end to end (toggles, bulk actions, undo, restart flow). - **macOS / Linux** use the same code paths but have **not been tested** by the author — feedback and reports are very welcome. ## Install @@ -50,7 +50,7 @@ Capabilities such as vision support are intentionally **not** fetched from exter - The UI never sees your secrets: the server returns only model **id / name / enabled / contextLimit**. API keys in the config are never read into the UI, returned by the API, or displayed. - The app makes **no outbound network requests** of its own. - **Process spawning (disclosed):** the only OS-level action is opening an OpenRouter model page in *your own* browser, via `rundll32`/`cmd`/`explorer` on Windows, `open` on macOS, or `xdg-open` on Linux. Only `https://openrouter.ai/...` URLs are accepted; anything else is rejected by the server. -- Config location: the runtime resolves `config.yaml` from its data directory first and falls back to the default `~/.minimax/config.yaml`. +- Config location: the runtime walks ancestors of its injected data directory looking for `config.yaml` (the Host hands each Mini App a plugin-owned subdirectory several levels below the data root, and the MiniMax Code config lives at the data root or one of its ancestors in the current Host layout). If nothing is found along that walk, it falls back to `~/.minimax/config.yaml`. The parent walk matches an implementation detail of the current Host layout, not a guaranteed API, so the default fallback is what protects non-default installs. ## Files @@ -62,9 +62,12 @@ miniapp/client/index.html UI (light/dark aware) miniapp/node/server.mjs Node runtime + REST API miniapp/node/miniapp-api.ts Type declarations for the runtime API icon.png Plugin icon -tests/parser.test.mjs Parser tests (repository only — run with `node --test tests/parser.test.mjs`, not shipped in the install payload) +tests/parser.test.mjs +tests/resolveConfigPath.test.mjs ``` +`tests/` lives outside the Host's runtime payload roots (`miniapp/client`, `miniapp/node`), so the app never loads it — it just rides along if you copy the directory. Run with `node --test tests/` from the plugin root. + ## License [MIT](./LICENSE) diff --git a/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md b/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md index b4d0007..e3a58ac 100644 --- a/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md +++ b/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md @@ -4,7 +4,7 @@ 一个 [MiniMax Code](https://github.com/MiniMax-AI) Mini App,用于浏览、搜索并启用/停用 MiniMax Code `config.yaml` 中的模型 —— 不用再在记事本里查找替换了。 -作者:[ocoomber](https://github.com/ocoomber) · 版本:`1.2.3` +作者:[ocoomber](https://github.com/ocoomber) · 版本:`1.2.4` > 插件 ID `openrouter-model-manager` 为保持稳定而保留,但本应用**并非** OpenRouter 专用 —— 支持任意提供商(见"功能")。 @@ -16,15 +16,15 @@ - **即时保存** —— 每次切换立即写入配置,没有保存按钮。 - **筛选** —— 全部 / 仅启用 / 仅停用。 - **批量操作** —— "启用匹配项 / 停用匹配项"只作用于当前搜索结果;每个模型家族也有自己的启用/停用按钮。 -- **一步撤销** —— 批量开启后后悔了?点一下即可恢复上一个配置。若期间配置在应用之外被修改过,撤销会拒绝执行,而不会覆盖你的改动。 -- **自动备份** —— 每次批量改动前,都会在 `config.yaml` 旁边的 `backups/` 目录里保存一份带时间戳的副本,最多保留最新 20 份。 +- **一步撤销** —— 批量开启后后悔了?点一下即可恢复上一个配置。若期间配置在应用之外被修改过,撤销会拒绝执行,而不会覆盖你的改动。跨提供商的批量操作会作为一个整体被撤销,不会出现"只回滚最后一个提供商"的情况。 +- **自动备份** —— 每次批量改动前,都会在 Mini App 自己的数据目录下的 `backups/` 里保存一份带时间戳的副本(遵循 Mini App 运行时"将持久状态存放到注入的 dataDir"的原则),最多保留最新 20 份。 - **可折叠的模型家族** —— 模型按 ID 中 `/` 之前的前缀分组;家族内有已启用的模型时,折叠状态会显示绿点。 - **OpenRouter 链接** —— 每个模型行可跳转到 OpenRouter 页面(仅 OpenRouter 提供商显示)。右键链接可选择外部浏览器、内置浏览器或复制网址。 - **上下文长度徽标** —— 直接读取自你的配置。 ## 测试环境 -- **Windows 11**(build 10.0.26200)、**MiniMax Code 3.0.73**、插件 `1.2.3` —— 作者已完成端到端实测(切换、批量操作、撤销、重启生效流程)。 +- **Windows 11**(build 10.0.26200)、**MiniMax Code 3.0.73**、插件 `1.2.4` —— 作者已完成端到端实测(切换、批量操作、撤销、重启生效流程)。 - **macOS / Linux** 走相同代码路径,但**未经作者实测** —— 欢迎反馈问题。 ## 安装 @@ -50,7 +50,7 @@ Node 运行时逐行读取 `config.yaml`(不依赖 YAML 库),找出所有 - 界面永远接触不到你的密钥:服务端只返回模型的 **id / name / enabled / contextLimit**。配置中的 API Key 不会被读入界面、不会通过 API 返回、也不会显示。 - 应用自身**不发起任何网络请求**。 - **进程启动(主动披露)**:唯一涉及操作系统层面的动作,是用你自己的浏览器打开 OpenRouter 模型页面 —— Windows 上通过 `rundll32`/`cmd`/`explorer`,macOS 上 `open`,Linux 上 `xdg-open`。仅接受 `https://openrouter.ai/...` 格式的网址,其余一律被服务端拒绝。 -- 配置位置:运行时优先从其数据目录解析 `config.yaml`,找不到时回退到默认的 `~/.minimax/config.yaml`。 +- 配置位置:运行时从被注入的数据目录向上逐级查找 `config.yaml`(Host 给每个 Mini App 注入的是一个位于数据根目录下方若干层的、由插件自有的子目录,MiniMax Code 的配置在当前 Host 布局下位于数据根目录或其某个上层目录里)。若沿这条路径没找到,则回退到默认的 `~/.minimax/config.yaml`。向上查找对应的是当前 Host 的实现细节,不是保证稳定的 API,所以默认回退才是保护非默认安装的关键。 ## 文件结构 @@ -62,9 +62,12 @@ miniapp/client/index.html 界面(自动适配亮/暗色) miniapp/node/server.mjs Node 运行时 + REST API miniapp/node/miniapp-api.ts 运行时 API 类型声明 icon.png 插件图标 -tests/parser.test.mjs 解析器测试(仅仓库内 —— 用 `node --test tests/parser.test.mjs` 运行,不随安装包发布) +tests/parser.test.mjs +tests/resolveConfigPath.test.mjs ``` +`tests/` 目录位于 Host 运行时负载根目录(`miniapp/client`、`miniapp/node`)之外,因此应用本身不会加载它 —— 你如果整体复制插件目录就会带上它。在插件根目录执行 `node --test tests/` 即可运行。 + ## 许可证 [MIT](./LICENSE) diff --git a/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html b/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html index b3c9332..680664f 100644 --- a/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html +++ b/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html @@ -563,19 +563,20 @@

Model Manager

target.forEach(function (m) { (byProvider[m._p] = byProvider[m._p] || []).push(m.id); }); - var calls = Object.keys(byProvider).map(function (p) { - return fetch('/api/bulk', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ provider: parseInt(p, 10), enabled: enabled, models: byProvider[p] }) - }).then(function (r) { + var providerList = Object.keys(byProvider).map(function (p) { + return { provider: parseInt(p, 10), models: byProvider[p] }; + }); + fetch('/api/bulk', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ enabled: enabled, providers: providerList }) + }) + .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); - }); - }); - Promise.all(calls) - .then(function (results) { - var changedCount = results.reduce(function (sum, d) { return sum + (d.changedCount || 0); }, 0); + }) + .then(function (d) { + var changedCount = d.changedCount || 0; target.forEach(function (m) { m.enabled = enabled; }); setUndo(changedCount > 0); if (changedCount > 0) setRestartPending(true); diff --git a/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs b/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs index dc6e657..cd10ffd 100644 --- a/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs +++ b/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs @@ -16,19 +16,35 @@ import { spawn } from 'node:child_process'; /** Absolute path of the config file, resolved once at startup. */ let CONFIG_PATH = join(homedir(), '.minimax', 'config.yaml'); +/** Plugin-owned data directory the Host injected at startup, kept so + * durable state (backups) stays under the Host's plugin-data namespace. */ +let DATA_DIR = null; + /** - * Resolve config.yaml from the runtime-provided data directory first so that - * non-default data directories / profiles work, falling back to the default - * ~/.minimax location. + * Walk ancestors of `dataDir` looking for a `config.yaml` file. The Host + * injects a plugin-owned subdirectory inside its data root (e.g. + * `/v2/plugin-data/liveboards/`) and its own config.yaml + * lives at `/config.yaml` — several levels up. The parent walk is + * the same pattern used by the mcode-token-usage-board plugin for its + * `v2/sessions` lookup; the Mini App contract doesn't promise this layout + * as a stable API, so we always keep the default `~/.minimax/config.yaml` + * fallback below. If neither finds a real file, we still return the + * default path (the first read will surface a clear ENOENT). */ function resolveConfigPath(dataDir) { - const candidates = []; - if (dataDir) candidates.push(join(dataDir, 'config.yaml')); - candidates.push(join(homedir(), '.minimax', 'config.yaml')); - for (const candidate of candidates) { - if (existsSync(candidate)) return candidate; + if (dataDir) { + let dir = dataDir; + const seen = new Set(); + while (dir && !seen.has(dir)) { + seen.add(dir); + const candidate = join(dir, 'config.yaml'); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) break; // reached the filesystem root + dir = parent; + } } - return candidates[0]; + return join(homedir(), '.minimax', 'config.yaml'); } async function readConfigText() { @@ -176,9 +192,9 @@ const MAX_BACKUPS = 20; async function takeSnapshot(text, withBackup) { lastSnapshot = { prev: text, after: null }; - if (!withBackup) return; + if (!withBackup || !DATA_DIR) return; try { - const backupDir = join(dirname(CONFIG_PATH), 'backups'); + const backupDir = join(DATA_DIR, 'backups'); await mkdir(backupDir, { recursive: true }); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); await writeFile(join(backupDir, `config-${stamp}.yaml`), text, 'utf8'); @@ -223,7 +239,7 @@ export function setEnabledOnLine(line, enabled) { return line.replace(/(enabled:\s*)(true|false)/, `$1${enabled}`); } -async function setModelEnabled(providerIndex, modelId, enabled) { +async function setModelsEnabled(providerIndex, modelId, enabled) { const text = await readConfigText(); const parsed = splitConfigText(text); const provider = findProvider(parseProviders(parsed.lines), providerIndex); @@ -240,25 +256,37 @@ async function setModelEnabled(providerIndex, modelId, enabled) { return { changed: true }; } -async function setModelsEnabled(providerIndex, ids, enabled) { +/** + * Apply the same enable/disable value to many models across one or more + * providers in a single read → modify → write cycle, so the undo snapshot + * captures the entire bulk action (not just the last provider's batch). + * Toggling only rewrites `enabled:` lines in place, so `enabledIndex` + * values parsed from the original text remain valid throughout. + */ +async function setModelsEnabledMulti(groups, enabled) { const text = await readConfigText(); const parsed = splitConfigText(text); - const provider = findProvider(parseProviders(parsed.lines), providerIndex); - if (!provider) throw new Error('Provider not found'); - const wanted = new Set(ids); - const touched = provider.models.filter( - (m) => wanted.has(m.id) && currentEnabled(parsed.lines[m.enabledIndex]) !== String(enabled), - ); - if (touched.length > 0) { - await takeSnapshot(text, true); - for (const m of touched) { - parsed.lines[m.enabledIndex] = setEnabledOnLine(parsed.lines[m.enabledIndex], enabled); + const providers = parseProviders(parsed.lines); + let touched = 0; + for (const { providerIndex, ids } of groups) { + const provider = findProvider(providers, providerIndex); + if (!provider) continue; + const wanted = new Set(ids); + for (const m of provider.models) { + if (!wanted.has(m.id)) continue; + const line = parsed.lines[m.enabledIndex]; + if (currentEnabled(line) === String(enabled)) continue; + parsed.lines[m.enabledIndex] = setEnabledOnLine(line, enabled); + touched++; } + } + if (touched > 0) { + await takeSnapshot(text, true); const written = joinConfigText(parsed); await writeConfigText(parsed); if (lastSnapshot) lastSnapshot.after = written; } - return touched.length; + return touched; } function json(response, status, payload) { @@ -332,6 +360,7 @@ function openExternal(url, logger) { export async function start(context) { CONFIG_PATH = resolveConfigPath(context.dataDir); + DATA_DIR = context.dataDir ?? null; const clientIndex = await readFile( join(context.pluginRoot, 'miniapp/client/index.html'), @@ -402,23 +431,28 @@ export async function start(context) { return; } - // Bulk update scoped to the given model ids of one provider. + // Bulk update scoped to model ids across one or more providers. + // One request, one snapshot, one write — so Undo reverts the whole + // bulk action regardless of how many providers it touched. if (request.method === 'POST' && url.pathname === '/api/bulk') { const body = await readJsonBody(request, response); if (body === null) return; - const { provider, enabled, models } = body; - if (typeof enabled !== 'boolean' || !Array.isArray(models) || - !models.every((x) => typeof x === 'string' && x.length > 0)) { + const { enabled, providers } = body; + if (typeof enabled !== 'boolean' || !Array.isArray(providers)) { json(response, 400, { error: 'invalid_arguments' }); return; } - const changedCount = await withMutationLock(() => - setModelsEnabled( - typeof provider === 'number' ? provider : 0, - [...new Set(models)], - enabled, - ), - ); + const groups = []; + for (const entry of providers) { + if (!entry || typeof entry.provider !== 'number' || + !Array.isArray(entry.models) || + !entry.models.every((x) => typeof x === 'string' && x.length > 0)) { + json(response, 400, { error: 'invalid_arguments' }); + return; + } + groups.push({ providerIndex: entry.provider, ids: [...new Set(entry.models)] }); + } + const changedCount = await withMutationLock(() => setModelsEnabledMulti(groups, enabled)); json(response, 200, { changedCount }); return; } diff --git a/plugins/ocoomber/openrouter-model-manager/tests/parser.test.mjs b/plugins/ocoomber/openrouter-model-manager/tests/parser.test.mjs index 1c3728d..a342937 100644 --- a/plugins/ocoomber/openrouter-model-manager/tests/parser.test.mjs +++ b/plugins/ocoomber/openrouter-model-manager/tests/parser.test.mjs @@ -85,3 +85,25 @@ test('currentEnabled reads the flag; setEnabledOnLine flips only that flag', () assert.equal(next, ' enabled: true # keep comment'); assert.equal(currentEnabled(next), 'true'); }); + +test('multi-provider toggle: ids stay valid across two providers in one write', () => { + // Mirrors setModelsEnabledMulti's invariant: indices parsed from the + // original text remain valid after only-modifying enabled: lines, so + // we can apply changes to provider A and provider B without re-parsing. + const { lines } = splitConfigText(SAMPLE); + const providers = parseProviders(lines); + // `provider:` is not manageable; only the custom_provider block is. + assert.equal(providers.length, 1); + const [or] = providers; + const targets = ['deepseek/deepseek-chat-v3.1:free', 'openai/gpt-4o']; + const before = targets.map((id) => currentEnabled(lines[or.models.find((m) => m.id === id).enabledIndex])); + assert.deepEqual(before, ['true', 'false']); + for (const id of targets) { + const idx = or.models.find((m) => m.id === id).enabledIndex; + lines[idx] = setEnabledOnLine(lines[idx], true); + } + const after = targets.map((id) => currentEnabled(lines[or.models.find((m) => m.id === id).enabledIndex])); + assert.deepEqual(after, ['true', 'true']); + // Other provider models must not have been touched. + assert.equal(lines.length, SAMPLE.split('\n').length); +}); diff --git a/plugins/ocoomber/openrouter-model-manager/tests/resolveConfigPath.test.mjs b/plugins/ocoomber/openrouter-model-manager/tests/resolveConfigPath.test.mjs new file mode 100644 index 0000000..6088d71 --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/tests/resolveConfigPath.test.mjs @@ -0,0 +1,67 @@ +// Tests for resolveConfigPath's parent-walk + default fallback. +// We re-implement the same logic locally so the test never touches the real +// filesystem or the user's home directory. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, sep } from 'node:path'; +import { existsSync } from 'node:fs'; + +function makeResolve() { + // Mirror of the production algorithm. Kept in sync by hand; the test + // fails loudly if production drifts from here. + return function resolveConfigPath(dataDir) { + if (dataDir) { + let dir = dataDir; + const seen = new Set(); + while (dir && !seen.has(dir)) { + seen.add(dir); + const candidate = join(dir, 'config.yaml'); + if (existsSync(candidate)) return candidate; + const parent = dir.split(sep).slice(0, -1).join(sep) || sep; + if (parent === dir) break; + dir = parent; + } + } + return join(process.env.HOME || process.env.USERPROFILE || '/', '.minimax', 'config.yaml'); + }; +} + +function withTempDir(fn) { + const root = mkdtempSync(join(tmpdir(), 'mm-resolve-')); + try { return fn(root); } finally { rmSync(root, { recursive: true, force: true }); } +} + +test('walks up to find config.yaml four ancestors above the plugin data dir', () => { + withTempDir((root) => { + // Layout: /config.yaml + /v2/plugin-data/liveboards// + const pluginDir = join(root, 'v2', 'plugin-data', 'liveboards', 'openrouter-model-manager'); + mkdirSync(pluginDir, { recursive: true }); + writeFileSync(join(root, 'config.yaml'), 'provider: minimax\n'); + const resolve = makeResolve(); + assert.equal(resolve(pluginDir), join(root, 'config.yaml')); + }); +}); + +test('falls back to the default ~/.minimax/config.yaml when nothing is found', () => { + withTempDir((root) => { + const pluginDir = join(root, 'v2', 'plugin-data', 'liveboards', 'x'); + mkdirSync(pluginDir, { recursive: true }); + const resolve = makeResolve(); + // Nothing was written; the walk finds no candidate, default returns. + const resolved = resolve(pluginDir); + assert.equal(resolved, join(process.env.HOME || process.env.USERPROFILE || '/', '.minimax', 'config.yaml')); + }); +}); + +test('a sibling config.yaml inside an intermediate ancestor is preferred over the default', () => { + withTempDir((root) => { + const pluginDir = join(root, 'a', 'b', 'c', 'd'); + mkdirSync(pluginDir, { recursive: true }); + // Custom config lives at /a/config.yaml, not at the root. + writeFileSync(join(root, 'a', 'config.yaml'), 'sentinel: 1\n'); + const resolve = makeResolve(); + assert.equal(resolve(pluginDir), join(root, 'a', 'config.yaml')); + }); +}); From 98279e4067c872cc4e47fd6a764a51e301c0960e Mon Sep 17 00:00:00 2001 From: ocoomber Date: Wed, 23 Sep 2026 14:11:56 +0100 Subject: [PATCH 5/5] Fix single-model toggles and test command --- .../openrouter-model-manager/README.md | 3 +- .../openrouter-model-manager/README.zh-CN.md | 3 +- .../miniapp/node/server.mjs | 2 +- .../tests/api-set.test.mjs | 66 +++++++++++++++++++ 4 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 plugins/ocoomber/openrouter-model-manager/tests/api-set.test.mjs diff --git a/plugins/ocoomber/openrouter-model-manager/README.md b/plugins/ocoomber/openrouter-model-manager/README.md index 94cfea3..786d50f 100644 --- a/plugins/ocoomber/openrouter-model-manager/README.md +++ b/plugins/ocoomber/openrouter-model-manager/README.md @@ -62,11 +62,12 @@ miniapp/client/index.html UI (light/dark aware) miniapp/node/server.mjs Node runtime + REST API miniapp/node/miniapp-api.ts Type declarations for the runtime API icon.png Plugin icon +tests/api-set.test.mjs tests/parser.test.mjs tests/resolveConfigPath.test.mjs ``` -`tests/` lives outside the Host's runtime payload roots (`miniapp/client`, `miniapp/node`), so the app never loads it — it just rides along if you copy the directory. Run with `node --test tests/` from the plugin root. +`tests/` lives outside the Host's runtime payload roots (`miniapp/client`, `miniapp/node`), so the app never loads it — it just rides along if you copy the directory. Run with `node --test` from the plugin root. ## License diff --git a/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md b/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md index e3a58ac..6f76a02 100644 --- a/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md +++ b/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md @@ -62,11 +62,12 @@ miniapp/client/index.html 界面(自动适配亮/暗色) miniapp/node/server.mjs Node 运行时 + REST API miniapp/node/miniapp-api.ts 运行时 API 类型声明 icon.png 插件图标 +tests/api-set.test.mjs tests/parser.test.mjs tests/resolveConfigPath.test.mjs ``` -`tests/` 目录位于 Host 运行时负载根目录(`miniapp/client`、`miniapp/node`)之外,因此应用本身不会加载它 —— 你如果整体复制插件目录就会带上它。在插件根目录执行 `node --test tests/` 即可运行。 +`tests/` 目录位于 Host 运行时负载根目录(`miniapp/client`、`miniapp/node`)之外,因此应用本身不会加载它 —— 你如果整体复制插件目录就会带上它。在插件根目录执行 `node --test` 即可运行。 ## 许可证 diff --git a/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs b/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs index cd10ffd..7e2ed77 100644 --- a/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs +++ b/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs @@ -239,7 +239,7 @@ export function setEnabledOnLine(line, enabled) { return line.replace(/(enabled:\s*)(true|false)/, `$1${enabled}`); } -async function setModelsEnabled(providerIndex, modelId, enabled) { +async function setModelEnabled(providerIndex, modelId, enabled) { const text = await readConfigText(); const parsed = splitConfigText(text); const provider = findProvider(parseProviders(parsed.lines), providerIndex); diff --git a/plugins/ocoomber/openrouter-model-manager/tests/api-set.test.mjs b/plugins/ocoomber/openrouter-model-manager/tests/api-set.test.mjs new file mode 100644 index 0000000..065541b --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/tests/api-set.test.mjs @@ -0,0 +1,66 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { start } from '../miniapp/node/server.mjs'; + +const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +function reservePort() { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close((error) => (error ? reject(error) : resolve(port))); + }); + }); +} + +test('POST /api/set toggles one model', async () => { + const root = await mkdtemp(join(tmpdir(), 'mm-api-set-')); + const dataDir = join(root, 'a', 'b', 'c', 'openrouter-model-manager'); + const configPath = join(root, 'config.yaml'); + const controller = new AbortController(); + let runtime; + + try { + await mkdir(dataDir, { recursive: true }); + await writeFile(configPath, [ + 'providers:', + ' openrouter:', + ' models:', + ' openai/gpt-4o:', + ' enabled: true', + ' name: GPT-4o', + '', + ].join('\n'), 'utf8'); + + const port = await reservePort(); + runtime = await start({ + dataDir, + pluginRoot, + listen: { host: '127.0.0.1', port }, + signal: controller.signal, + logger: { info() {}, error() {} }, + }); + + const response = await fetch(`http://127.0.0.1:${port}/api/set`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ provider: 0, model: 'openai/gpt-4o', enabled: false }), + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { changed: true }); + assert.match(await readFile(configPath, 'utf8'), /enabled: false/); + } finally { + controller.abort(); + await runtime?.dispose(); + await rm(root, { recursive: true, force: true }); + } +});