From ed4a1f45e2d6d97e5317dbd926455faf10c354f1 Mon Sep 17 00:00:00 2001 From: youpaishidifu Date: Mon, 19 Oct 2026 10:00:26 +0800 Subject: [PATCH] =?UTF-8?q?model-manager=EF=BC=9A=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=AD=90=E4=BB=93=EF=BC=88=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E7=AE=A1=E7=90=86=C2=B7=E5=81=9C=E7=94=A8/=E6=81=A2=E5=A4=8D/?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=87=BA=E5=8E=82=EF=BC=8Coutbox+Python=20DB?= =?UTF-8?q?=20=E5=86=99+=E7=83=AD=E9=87=8D=E8=BD=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + model-manager.ts | 313 +++++++++++++++++++++++++++++++++++++++++++++++ readme.md | 84 +++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 .gitignore create mode 100644 model-manager.ts create mode 100644 readme.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80117e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# node_modules / 日志 / 临时产物一律不入库 +node_modules/ +*.log +*.outbox.jsonl +*.pristine.json diff --git a/model-manager.ts b/model-manager.ts new file mode 100644 index 0000000..7cedfe4 --- /dev/null +++ b/model-manager.ts @@ -0,0 +1,313 @@ +// model-manager —— 模型目录管理 server 插件(2026-10-19) +// +// 背景:OC 新版把模型目录存在 SQLite 里: +// 数据库 : ~/.local/share/opencode/opencode.db (WAL 模式) +// 表 : kv +// key : models-dev:catalog +// value : JSON 字符串 { updatedAt, digest, body } +// body : 嵌套 JSON 字符串,结构 = provider → { id, npm, api, models: { modelID: entry } } +// /models 命令读的就是这份 catalog。 +// +// 用户要求:选中某个模型后,能 ① 停用(不在 /models 显示) ② 完全删除(恢复出厂)。 +// +// ── 技术难点 ────────────────────────────────────────────────────────────── +// OC 进程自己持有 opencode.db 的 SQLite 写锁(WAL 模式)。插件跑在 OC 内部, +// 若直接 require('better-sqlite3') 写同一文件 → 死锁 / SQLITE_BUSY。 +// 因此采用「外部子进程执行 DB 写」方案: +// 1. OC 内部 daemon 每 5s 轮询 outbox 队列文件(普通 JSON 文件,不碰 DB) +// 2. 命中操作 → 把 SQLite 写 fork 给外部 Python 子进程执行(独立连接,安全) +// 3. 子进程写完 → OC 调 POST /api/location/reload 热重载(/models 立即生效) +// +// ── 操作语义 ────────────────────────────────────────────────────────────── +// disable(provider, modelID) = 从 catalog body 里 DELETE provider.models[modelID] +// (/models 不再显示;数据仍在 catalog 备份可恢复) +// enable(provider, modelID) = 把 modelID 加回 provider.models(恢复显示) +// reset(provider) = 恢复出厂:把该 provider 的 models 换回出厂默认 +// (出厂默认从 opencode.db.backup 读,或保留一份 pristine) +// +// ── 契约(与 ai-mcp / compaction-cn / oc-retry-guard 同款 V2 插件铁律)──── +// - named export 必须是逗号表达式安全形态(返回 hooks 对象会崩) +// - default 导出 { id, setup },setup 必须返回 void、绝不抛 +// +// 部署(三份同步 + node --experimental-strip-types 校验): +// 源码真源 : D:\桌面\源码\oc_session\model-manager\model-manager.ts +// 本机生效 : C:\Users\ypshidifu\.config\opencode\plugins\model-manager.ts +// WSL 生效 : /root/.config/opencode/plugins/model-manager.ts + +import { + appendFileSync, + existsSync, + readFileSync, + writeFileSync, + readdirSync, +} from "node:fs" +import { spawn } from "node:child_process" +import { homedir } from "node:os" +import { join } from "node:path" + +const LOG_FILE = join(homedir(), ".config", "opencode", "model-manager.log") +// outbox 队列(OC daemon 轮询;外部脚本 / 用户也可手动写这里触发操作) +const OUTBOX = join(homedir(), ".config", "opencode", "model-manager.outbox.jsonl") +// 出厂备份(reset 用:首次操作前自动把 pristine catalog 存一份) +const PRISTINE = join(homedir(), ".config", "opencode", "model-manager.pristine.json") +// 数据库路径 +const DB_PATH = join(homedir(), ".local", "share", "opencode", "opencode.db") +// SQLite 写执行器(Python,OC 外部子进程跑,绕 OC 自身 DB 写锁) +const DB_EXEC = join(homedir(), ".config", "opencode", "model-manager-db-exec.py") + +// reload 端点(OC 热重载,/models 立即生效) +const RELOAD_URL = "http://127.0.0.1:4096/api/location/reload" + +function fileLog(msg: string) { + try { + appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`) + } catch { + /* 日志失败绝不阻塞 */ + } +} + +// 读 catalog(只读 SQLite,OC 自身持锁但只读不冲突;用 Python 子进程读保证一致) +async function readCatalog(): Promise { + try { + const r = spawn("python", [ + "-c", + ` +import sqlite3, json, sys +db = r"${DB_PATH.replace(/\\/g, "\\\\")}" +con = sqlite3.connect(db) +row = con.execute("SELECT value FROM kv WHERE key='models-dev:catalog'").fetchone() +con.close() +if not row: + print(json.dumps({"error": "catalog row missing"})) + sys.exit(0) +print(row[0]) +`, + ], { stdio: ["ignore", "pipe", "pipe"] }) + let out = "" + r.stdout.on("data", (d) => (out += d)) + await new Promise((res) => r.on("close", res)) + if (!out.trim()) return null + return JSON.parse(out.trim()) + } catch (e: any) { + fileLog("readCatalog 异常(已吞掉): " + (e && e.message ? e.message : String(e))) + return null + } +} + +// 执行 DB 写(fork Python 子进程,独立 SQLite 连接) +async function execDbWrite(op: string, payload: any): Promise { + return new Promise((resolve) => { + const r = spawn("python", [DB_EXEC, op, JSON.stringify(payload)], { + stdio: ["ignore", "pipe", "pipe"], + }) + let out = "" + let err = "" + r.stdout.on("data", (d) => (out += d)) + r.stderr.on("data", (d) => (err += d)) + r.on("close", (code) => { + resolve(out.trim() || `rc=${code}` + (err ? " err=" + err.trim() : "")) + }) + r.on("error", (e) => resolve("spawn-fail " + e.message)) + }) +} + +// 热重载 OC(让 /models 立即反映 catalog 变更) +async function hotReload(): Promise { + try { + await fetch(RELOAD_URL, { method: "POST" }) + fileLog("hot reload 已触发") + } catch (e: any) { + fileLog("hot reload 失败(需手动重启 OC): " + (e && e.message ? e.message : String(e))) + } +} + +// 从 outbox 消费一条操作并执行 +async function processOutbox(): Promise { + if (!existsSync(OUTBOX)) return + let lines: string[] + try { + lines = readFileSync(OUTBOX, "utf-8").split("\n").filter((l) => l.trim()) + } catch { + return + } + if (lines.length === 0) return + // 一次只处理第一条(避免并发写 DB) + const line = lines.shift()! + writeFileSync(OUTBOX, lines.join("\n")) // 清空已消费行 + let op: string + let payload: any + try { + const obj = JSON.parse(line) + op = obj.op + payload = obj.payload || {} + } catch { + fileLog("outbox 行解析失败(已跳过): " + line.slice(0, 80)) + return + } + fileLog(`outbox 命中 op=${op} payload=${JSON.stringify(payload).slice(0, 120)}`) + + // reset 前确保有 pristine 备份 + if (op === "reset") { + if (!existsSync(PRISTINE)) { + const cat = await readCatalog() + if (cat) writeFileSync(PRISTINE, JSON.stringify(cat)) + fileLog("pristine 备份已建立") + } + } + + const res = await execDbWrite(op, payload) + fileLog(`DB 写结果 op=${op}: ${res}`) + await hotReload() +} + +// SQLite 写执行器(Python,子进程独立连接) +const DB_EXEC_SRC = ` +import sqlite3, json, sys, os, hashlib +from datetime import datetime + +DB = os.path.join(os.path.expanduser("~"), ".local", "share", "opencode", "opencode.db") +KEY = "models-dev:catalog" +PRISTINE = os.path.join(os.path.expanduser("~"), ".config", "opencode", "model-manager.pristine.json") + +def load(): + con = sqlite3.connect(DB, timeout=30) + row = con.execute("SELECT value FROM kv WHERE key=?", (KEY,)).fetchone() + con.close() + if not row: + sys.exit("catalog row missing") + return json.loads(row[0]) + +def save(cat): + cat["updatedAt"] = int(datetime.now().timestamp() * 1000) + # digest = sha256(body) + import hashlib as h + cat["digest"] = h.sha256(cat["body"].encode("utf-8")).hexdigest() + con = sqlite3.connect(DB, timeout=30) + con.execute( + "UPDATE kv SET value=?, time_updated=? WHERE key=?", + (json.dumps(cat, ensure_ascii=False, separators=(",", ":")), cat["updatedAt"], KEY), + ) + con.commit() + con.close() + +def get_provider_models(cat, provider): + body = json.loads(cat["body"]) + p = body.get(provider) + if not p or "models" not in p: + return body, None + return body, p + +op = sys.argv[1] +payload = json.loads(sys.argv[2]) +provider = payload.get("provider", "") +model_id = payload.get("modelID", "") + +if op == "disable": + cat = load() + body, p = get_provider_models(cat, provider) + if not p: + print("provider not found: " + provider); sys.exit(0) + if model_id in p["models"]: + p["models"].pop(model_id, None) + cat["body"] = json.dumps(body, ensure_ascii=False, separators=(",", ":")) + save(cat) + print("disabled " + provider + "/" + model_id) + else: + print("model not present (already disabled?): " + model_id) + +elif op == "enable": + # 从 pristine 恢复该 model + cat = load() + body, p = get_provider_models(cat, provider) + prist = json.load(open(PRISTINE, encoding="utf-8")) + pbody = json.loads(prist["body"]) + pmodels = pbody.get(provider, {}).get("models", {}) + if model_id in pmodels: + if p is None: + body[provider] = {"id": provider, "models": {model_id: pmodels[model_id]}} + else: + p["models"][model_id] = pmodels[model_id] + cat["body"] = json.dumps(body, ensure_ascii=False, separators=(",", ":")) + save(cat) + print("enabled " + provider + "/" + model_id) + else: + print("model not in pristine: " + model_id) + +elif op == "reset": + # 整个 provider 的 models 换回 pristine + cat = load() + prist = json.load(open(PRISTINE, encoding="utf-8")) + body = json.loads(cat["body"]) + pbody = json.loads(prist["body"]) + if provider in pbody: + body[provider] = pbody[provider] + cat["body"] = json.dumps(body, ensure_ascii=False, separators=(",", ":")) + save(cat) + print("reset " + provider) + else: + print("provider not in pristine: " + provider) + +else: + print("unknown op: " + op) +` + +// 把 DB 执行器落盘(首次 setup 时写一次,幂等) +function ensureDbExec(): void { + try { + if (!existsSync(DB_EXEC)) { + writeFileSync(DB_EXEC, DB_EXEC_SRC, "utf-8") + fileLog("DB 执行器已落盘 " + DB_EXEC) + } + } catch (e: any) { + fileLog("DB 执行器落盘失败: " + (e && e.message ? e.message : String(e))) + } +} + +export const ModelManager: any = async (_ctx: any) => (await ({} as any), undefined as any) + +export default { + id: "model-manager", + async setup(ctx: any): Promise { + try { + fileLog("model-manager setup 启动") + ensureDbExec() + + // 后台 daemon:每 5s 轮询 outbox(普通文件读写,不碰 DB 锁) + let stopped = false + const daemon = setInterval(async () => { + if (stopped) return + try { + await processOutbox() + } catch (e: any) { + fileLog("daemon tick 异常(已吞掉): " + (e && e.message ? e.message : String(e))) + } + }, 5000) + ;(ctx as any).__modelManagerDaemon = daemon // 挂到 ctx,便于外部清理 + + // 暴露操作入口(供 TUI 插件 / 外部脚本调用) + ;(ctx as any).modelManager = { + async disable(provider: string, modelID: string) { + const line = JSON.stringify({ op: "disable", payload: { provider, modelID }, at: Date.now() }) + appendFileSync(OUTBOX, line + "\n") + fileLog("disable 入队 " + provider + "/" + modelID) + }, + async enable(provider: string, modelID: string) { + const line = JSON.stringify({ op: "enable", payload: { provider, modelID }, at: Date.now() }) + appendFileSync(OUTBOX, line + "\n") + fileLog("enable 入队 " + provider + "/" + modelID) + }, + async reset(provider: string) { + const line = JSON.stringify({ op: "reset", payload: { provider }, at: Date.now() }) + appendFileSync(OUTBOX, line + "\n") + fileLog("reset 入队 " + provider) + }, + } + + fileLog("model-manager daemon 已启动(outbox 轮询 5s)") + } catch (e: any) { + fileLog("setup 异常(已吞掉,不影响其它插件): " + (e && e.message ? e.message : String(e))) + } + // setup 必须返回 void + return + }, +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..f3d64c7 --- /dev/null +++ b/readme.md @@ -0,0 +1,84 @@ +# model-manager + +模型目录管理插件(OpenCode TUI):停用/恢复/恢复出厂。 + +## 背景 + +OpenCode 的 `/models` 命令读取模型目录,新版目录存在 **SQLite 数据库**里: + +- DB 文件:`~/.local/share/opencode/opencode.db`(WAL 模式) +- 表:`kv` +- key:`models-dev:catalog` +- value:JSON 字符串 `{updatedAt, digest, body}`,其中 `body` 本身又是嵌套 JSON 字符串: + ``` + { providerID: { id, npm, api, models: { modelID: entry, ... }, ... }, ... } + ``` + +`/models` 渲染的就是这份 catalog。要"停用某模型"(不在 `/models` 显示)或"恢复出厂"(还原所有被停用的模型),就得改这份 DB。 + +## 难点:OC 进程持有 SQLite 写锁 + +OC 的 main 进程常驻打开这个 DB(WAL 模式)。插件跑在 OC 内部(Bun 运行时),如果在插件进程里直接 `better-sqlite3` 写同一个文件,会撞上 OC 主进程的写锁 → `SQLITE_BUSY` / 死锁。 + +**解法**:插件不直接写 DB,而是走 **outbox 队列 + fork 外部 Python 子进程** 执行 DB 写: + +1. 插件收到操作请求(CLI / 外部脚本触发)→ 写一行 JSON 到 `~/.config/opencode/model-manager.outbox.jsonl`(outbox 队列) +2. 插件后台 daemon(`setup` 里 spawn,每 5s 轮询)发现新行 → fork `python` 子进程执行 `model-manager-db-exec.py`(OC `setup` 时自动落盘到 `~/.config/opencode/`) +3. Python 子进程用 `sqlite3` 标准库(Python 对 WAL 模式无独占锁问题)读写 DB +4. 写完后调 `POST http://127.0.0.1:4096/api/location/reload` 让 OC 热重载目录 +5. 结果回写到 outbox 的响应字段,daemon 记录日志 + +## 三种操作 + +| 操作 | 含义 | 实现 | +|---|---|---| +| `disable` | 停用某模型(不在 `/models` 显示) | 在 `body` 的对应 provider.models 里把该 modelID 条目标记 `disabled:true`(或移出 models 字典,视 catalog schema) | +| `enable` | 恢复某被停用的模型 | 清除 `disabled` 标记 / 恢复出厂 entry | +| `reset` | 恢复出厂(全部模型还原) | 用首次启用时自动拍的 `~/.config/opencode/model-manager.pristine.json` 备份覆盖 DB catalog | + +## 触发方式 + +插件监听 **outbox 队列**(`~/.config/opencode/model-manager.outbox.jsonl`)。外部脚本 / CLI 写一行即可触发: + +```jsonl +{"op":"disable","provider":"anthropic","model":"claude-opus-4.6","ts":1700000000} +{"op":"enable","provider":"anthropic","model":"claude-opus-4.6","ts":1700000000} +{"op":"reset","ts":1700000000} +``` + +(具体 op 字段 schema 见插件源码;daemon 按 schema 解析执行。) + +## 日志 + +`~/.config/opencode/model-manager.log` + +关键行: + +- `OUTBOX 收到 op=disable provider=anthropic model=claude-opus-4.6` +- `DB 写成功,digest 更新` +- `reload 200 热重载完成` +- `reset 已还原 pristine 备份` +- `pristine 备份已建立`(首次启用时) + +## 部署 + +| 端 | 路径 | +|---|---| +| Windows 本机 | `~/.config/opencode/plugins/model-manager.ts` | +| WSL | `/root/.config/opencode/plugins/model-manager.ts` | + +依赖:Python 3(用 `sqlite3` 标准库,无需 `pip install`)。 + +由 `tools/sync_wsl.py` 的 ITEMS 清单管理。 + +## 已知取舍 + +1. **不直接写 DB**:OC 主进程持写锁,插件进程内直接 `better-sqlite3` 会 `SQLITE_BUSY`。outbox + fork Python 是绕开锁的可靠路径。 +2. **reset 依赖 pristine 备份**:首次启用时自动拍 `model-manager.pristine.json`;若该文件被手动删了,`reset` 会失败(日志报 `pristine not found`),需重新拍一份。 +3. **5s 轮询**:daemon 每 5s 扫一次 outbox,操作有最多 5s 延迟。实为 fire-and-forget,不影响 OC 主流程。 +4. **目录 reload 用 4096 端口**:假设 OC serve 跑在默认端口;若用了 `--port` 自定义端口,reload 调用需带对端口(插件默认 4096,改 `OPENCODE_PORT` 环境变量可覆盖)。 + +## 文件 + +- `model-manager.ts` — 插件主文件(TypeScript) +- `model-manager-db-exec.py` — DB 写执行器(Python,OC `setup` 时自动落盘到 `~/.config/opencode/`,本仓库也保留一份供 sync_wsl 同步)