// 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. 队列排空 → 写 flush 标记 → 由 TUI 侧 tui.js 执行 location.reload 热重载 // (2026-10-19 实测定论:V2 server 插件 ctx 没有 client.location / serverUrl, // 进程内 reload 无通道 —— ai-mcp 2026-09-24 也报过同款「没有可用的 reload 通道」; // 而 TUI 插件 ctx 的 client.location 实测可用,见 oc-session-all.log 的 // 「location.reload OK」。所以热重载的正主在 tui.js,本插件只负责写 DB + 发标记) // // ── 操作语义 ────────────────────────────────────────────────────────────── // 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") // flush 标记:daemon 把队列排空并写完 DB 后落盘 { at, lastOp, serverReload }; // tui.js 常驻 poller 轮询到新 at 就执行 location.reload(TUI 侧才有 reload 通道) const FLUSH = join(homedir(), ".config", "opencode", "model-manager.last-flush.json") // ── 持久化停用配置 cfg.json(2026-10-19 用户指路引进)────────────────────── // 运行时路径:~/.config/opencode/model-manager.cfg.json(源码仓根目录 cfg.json 为模板) // 只认一个键:「以下是停用列表」(键名不区分大小写),值 = provider 级数组,支持 * 通配; // 其余键全是说明文档,全部忽略、写回时原样保留。 // 防御:opencode 更新可能删除我们的配置文件 → ensureCfg() 检测到丢失/损坏立即释放新配置, // 并从镜像 STOPLIST_MIRROR 恢复停用列表(停用状态不因 OC 更新丢失)。 const CFG_PATH = join(homedir(), ".config", "opencode", "model-manager.cfg.json") const STOPLIST_MIRROR = join(homedir(), ".config", "opencode", "model-manager.stoplist.bak.json") const STOP_KEY = "以下是停用列表" const CFG_TEMPLATE = `{ "停用清单": "持久化标记停用模型", "以下是停用列表": [], "配置格式": "一行一个,比如Xiaomi,deepseek等,*表示通配符,比如xiaomi-*则表示停用小米家族的全部模型(Xiaomi、Xiaomi-token-plan等)", "示例参数":[ "xiaomi", "DEEPSEEK", "longcat-*" ] }` // modelID 级镜像清单(单模型停用的持久化;整厂级走 cfg 的停用列表) const DISABLED_MIRROR = join(homedir(), ".config", "opencode", "model-manager.disabled.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") // 热重载通道见 doReload()(抄 ai-mcp:SDK client.location.reload 三级降级 + ctx.serverUrl 兜底, // 不再写死 4096 —— 用户环境 4096 无监听导致 reload 全挂、/models 不刷新) function fileLog(msg: string) { try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`) } catch { /* 日志失败绝不阻塞 */ } } // ── cfg.json:键名大小写不敏感定位「以下是停用列表」,其它键只保留不解读 ───── function stopKeyOf(obj: any): string | null { if (!obj || typeof obj !== "object") return null const target = STOP_KEY.toLowerCase() for (const k of Object.keys(obj)) { if (String(k).toLowerCase() === target) return k } return null } // 防御性保证:cfg 存在且可解析、含停用列表键;否则立即释放新配置 function ensureCfg(): void { try { if (existsSync(CFG_PATH)) { const raw = readFileSync(CFG_PATH, "utf-8") let j: any = null try { j = JSON.parse(raw) } catch { j = null } if (j && typeof j === "object" && !Array.isArray(j)) { if (!stopKeyOf(j)) { j[STOP_KEY] = [] writeFileSync(CFG_PATH, JSON.stringify(j, null, 2), "utf-8") fileLog("cfg 配置缺少「" + STOP_KEY + "」键,已补上(其余说明键原样保留)") } return } // 损坏:备份后重建 try { writeFileSync(CFG_PATH + ".bad", raw, "utf-8") } catch {} fileLog("cfg 配置损坏,已备份 .bad,立即释放新配置") } else { fileLog("cfg 配置文件丢失(可能被 opencode 更新清除),立即释放新配置: " + CFG_PATH) } let restored: string[] = [] try { if (existsSync(STOPLIST_MIRROR)) { const m = JSON.parse(readFileSync(STOPLIST_MIRROR, "utf-8")) if (Array.isArray(m)) restored = m.filter((x: any) => typeof x === "string") } } catch {} const tpl: any = JSON.parse(CFG_TEMPLATE) tpl[STOP_KEY] = restored writeFileSync(CFG_PATH, JSON.stringify(tpl, null, 2), "utf-8") fileLog( "新配置已释放" + (restored.length ? ",停用列表从镜像恢复 " + restored.length + " 项" : "(停用列表为空)"), ) } catch (e: any) { fileLog("ensureCfg 异常: " + (e && e.message ? e.message : String(e))) } } // 读停用列表(只认目标键,大小写不敏感;其它键全部忽略) function readStopList(): string[] { ensureCfg() try { const j = JSON.parse(readFileSync(CFG_PATH, "utf-8")) const k = stopKeyOf(j) const v = k ? j[k] : null if (Array.isArray(v)) return v.filter((x: any) => typeof x === "string" && x.trim()) if (typeof v === "string") return v.split(/[\n,]/).map((s: string) => s.trim()).filter(Boolean) return [] } catch { return [] } } // 写回停用列表(只改目标键,其余说明键原样保留)+ 同步镜像(防 OC 更新删文件丢状态) function writeStopList(list: string[]): void { try { ensureCfg() const j = JSON.parse(readFileSync(CFG_PATH, "utf-8")) const k = stopKeyOf(j) || STOP_KEY j[k] = list writeFileSync(CFG_PATH, JSON.stringify(j, null, 2), "utf-8") try { writeFileSync(STOPLIST_MIRROR, JSON.stringify(list, null, 2), "utf-8") } catch {} } catch (e: any) { fileLog("停用列表写回失败: " + (e && e.message ? e.message : String(e))) } } // 通配匹配(大小写不敏感):pattern 支持 * → 正则 fullmatch function patMatch(pattern: string, text: string): boolean { const p = String(pattern).toLowerCase() const t = String(text || "").toLowerCase() if (!p) return false if (p === t) return true if (p.includes("*")) { const re = new RegExp("^" + p.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i") return re.test(t) } return false } // 该 provider 是否被停用列表命中(id 或显示名 任一被精确/通配命中) function stopListHits(list: string[], id: string, name?: string): boolean { for (const pat of list) { if (patMatch(pat, id)) return true if (name && patMatch(pat, name)) return true } return false } // ── modelID 级镜像清单(单模型停用的持久化载体;与 cfg 的 provider 级列表互补)── type MirrorMap = Record function loadMirror(): MirrorMap { try { if (!existsSync(DISABLED_MIRROR)) return {} const j = JSON.parse(readFileSync(DISABLED_MIRROR, "utf-8")) const out: MirrorMap = {} for (const k of Object.keys(j || {})) { if (Array.isArray(j[k])) out[k] = j[k].filter((x: any) => typeof x === "string") } return out } catch { return {} } } function saveMirror(map: MirrorMap): void { try { writeFileSync(DISABLED_MIRROR, JSON.stringify(map, null, 2), "utf-8") } catch (e: any) { fileLog("停用镜像写入失败: " + (e && e.message ? e.message : String(e))) } } function mirrorMark(provider: string, modelID: string): void { const map = loadMirror() const arr = map[provider] || [] if (!arr.includes(modelID)) { arr.push(modelID) map[provider] = arr.sort() saveMirror(map) fileLog(`停用镜像已记录 ${provider}/${modelID}(共 ${arr.length} 项,重启/刷新后自动压制)`) } } function mirrorUnmark(provider: string, modelID: string): void { const map = loadMirror() if (!map[provider]) return map[provider] = map[provider].filter((x) => x !== modelID) if (!map[provider].length) delete map[provider] saveMirror(map) fileLog(`停用镜像已移除 ${provider}/${modelID}`) } // 读 catalog(只读 SQLite,OC 自身持锁但只读不冲突;用 Python 子进程读保证一致) async function readCatalog(): Promise { try { const py = process.platform === "win32" ? "python" : "python3" const r = spawn( py, [ "-c", ` import sqlite3, json, sys, os db = os.path.join(os.path.expanduser("~"), ".local", "share", "opencode", "opencode.db") con = sqlite3.connect(db) row = con.execute("SELECT value FROM kv WHERE key='models-dev:catalog'").fetchone() con.close() if not row: sys.stdout.buffer.write(json.dumps({"error": "catalog row missing"}).encode("utf-8")) sys.exit(0) sys.stdout.buffer.write(row[0].encode("utf-8")) `, ], { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" }, }, ) let out = "" r.stdout.on("data", (d) => (out += d.toString("utf-8"))) 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)) }) } // 热重载(进程内尽力通道,2026-10-19 实测:V2 server 插件 ctx 两条通道都没有, // 保留尝试只为将来宿主补上 client/serverUrl 时能自动生效): // 进程内 SDK client.location.reload 三种调用形态 → HTTP POST ctx.serverUrl /api/location/reload // 正常路径见 processOutbox 尾部:写 flush 标记 → tui.js 用 TUI ctx 的 client.location 热重载 // V2 上下文可能有 client 属性:优先 ctx.client(若宿主提供),否则回退 ctx 本身(抄 ai-mcp pickClient) function pickClient(ctx: any) { if (!ctx) return ctx if (ctx.client && (ctx.client.location || ctx.client.config)) return ctx.client return ctx } async function doReload(ctx: any): Promise { const attempts: Array<() => Promise> = [] const client = pickClient(ctx) if (client && client.location && typeof client.location.reload === "function") { attempts.push(() => client.location.reload({ location: undefined as any })) attempts.push(() => client.location.reload({})) attempts.push(() => client.location.reload()) } const serverUrl = ctx && (ctx as any).serverUrl if (serverUrl) { attempts.push(async () => { const res = await fetch(new URL("/api/location/reload", serverUrl), { method: "POST", headers: { "content-type": "application/json" }, body: "{}", }) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res }) } let lastErr: any = null for (const fn of attempts) { try { await fn() fileLog("hot reload 已触发(/models 应立即生效)") return true } catch (e: any) { lastErr = e } } const msg = lastErr ? lastErr.message : "进程内无 reload 通道(V2 server ctx 无 client.location/serverUrl,已实测)" fileLog("进程内 hot reload 未成功: " + msg + " → 接下来写 flush 标记,由 TUI 侧 tui.js 热重载(看 oc-session-all.log)") return false } // 队列深度(只数行,不改文件) function queueDepth(): number { try { if (!existsSync(OUTBOX)) return 0 return readFileSync(OUTBOX, "utf-8").split("\n").filter((l) => l.trim()).length } catch { return -1 // 读失败时按「未知」处理,宁可多等一轮 } } // 从 outbox 消费一条操作并执行 async function processOutbox(ctx: any): 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) const depth = queueDepth() fileLog(`DB 写结果 op=${op}: ${res},队列剩余=${depth}`) // ── 维护持久化停用状态(只有 DB 真实进入目标态才动)──────────────────── // provider 级 → cfg.json 的「以下是停用列表」(用户可见可手改) // modelID 级 → model-manager.disabled.json 镜像 try { const s = String(res) const prov = payload.provider if (op === "disable_all" && (s.startsWith("disabled_all ") || s.startsWith("already empty"))) { const list = readStopList() if (!list.some((p) => patMatch(p, prov))) { list.push(prov) writeStopList(list) fileLog(`停用列表已记录整厂 ${prov}(cfg 共 ${list.length} 项,持久化)`) } } else if (op === "disable" && (s.startsWith("disabled ") || s.startsWith("model not present"))) { mirrorMark(prov, payload.modelID) } else if (op === "enable" && s.startsWith("enabled ")) { mirrorUnmark(prov, payload.modelID) } else if (op === "enable" && s.startsWith("model not in pristine")) { mirrorUnmark(prov, payload.modelID) } else if (op === "reset" && (s.startsWith("reset ") || s.startsWith("provider not in pristine"))) { // 恢复出厂/格式化:整厂从停用列表摘除(含命中它的通配项),单模型镜像清空 const list = readStopList() const kept = list.filter((p) => !patMatch(p, prov)) const removed = list.length - kept.length if (removed) { writeStopList(kept) fileLog(`恢复出厂 ${prov}:停用列表移除 ${removed} 项(命中该厂的通配项一并移除)`) } const map = loadMirror() if (map[prov]) { delete map[prov] saveMirror(map) fileLog(`恢复出厂 ${prov}:单模型停用镜像已清空`) } } } catch (e: any) { fileLog("停用状态维护异常(已吞掉): " + (e && e.message ? e.message : String(e))) } if (depth !== 0) { // 还有排队的操作(如逐模型停用多条):先不 reload,等全部写完一次性热重载 fileLog("队列未排空,暂不 reload(等最后一条写完再一次性热重载)") return } await finishBatch(ctx, op) } // 队列排空后的收尾:进程内 reload 尝试 + 落 flush 标记(TUI 常驻 poller 据此热重载) async function finishBatch(ctx: any, tag: string): Promise { const serverReload = await doReload(ctx) try { writeFileSync(FLUSH, JSON.stringify({ at: Date.now(), lastOp: tag, serverReload: !!serverReload })) fileLog("flush 标记已写(TUI 侧将据此触发热重载): tag=" + tag + " serverReload=" + !!serverReload) } catch (e: any) { fileLog("flush 标记写入失败: " + (e && e.message ? e.message : String(e))) } } // SQLite 写执行器(Python,子进程独立连接) // 注意:Windows 控制台默认 GBK,provider/modelID 可能含非 GBK 字符 → print 会 UnicodeEncodeError。 // 所有 stdout 输出统一走 sys.stdout.buffer.write(s.encode('utf-8'))。 const DB_EXEC_SRC = ` import sqlite3, json, sys, os, hashlib from datetime import datetime def out(s): sys.stdout.buffer.write(str(s).encode("utf-8")) 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: out("catalog row missing"); sys.exit(0) return json.loads(row[0]) def save(cat): cat["updatedAt"] = int(datetime.now().timestamp() * 1000) 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: out("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) out("disabled " + provider + "/" + model_id) else: out("model not present (already disabled?): " + model_id) elif op == "disable_all": # 整厂下架:models 清空(恢复走 reset/enable 从 pristine 拉回) cat = load() body, p = get_provider_models(cat, provider) if not p: out("provider not found: " + provider); sys.exit(0) n = len(p["models"]) if n == 0: out("already empty: " + provider) else: p["models"] = {} cat["body"] = json.dumps(body, ensure_ascii=False, separators=(",", ":")) save(cat) out("disabled_all " + provider + " n=" + str(n)) 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) out("enabled " + provider + "/" + model_id) else: out("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) out("reset " + provider) else: out("provider not in pristine: " + provider) else: out("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))) } } // 读 catalog 的 time_updated(单值,便宜;用于判断 OC 是否刷新覆盖过 catalog) async function readCatalogTime(): Promise { return new Promise((resolve) => { try { const py = process.platform === "win32" ? "python" : "python3" const script = "import sqlite3,os,sys;" + "db=os.path.join(os.path.expanduser('~'),'.local','share','opencode','opencode.db');" + "con=sqlite3.connect(db);" + "row=con.execute(\"SELECT time_updated FROM kv WHERE key='models-dev:catalog'\").fetchone();" + "con.close();" + "sys.stdout.buffer.write(str(row[0] if row else 0).encode())" const r = spawn(py, ["-c", script], { stdio: ["ignore", "pipe", "pipe"] }) let out = "" r.stdout.on("data", (d) => (out += d.toString("utf-8"))) r.on("close", () => resolve(parseInt(out.trim(), 10) || 0)) r.on("error", () => resolve(0)) } catch { resolve(0) } }) } // ── 对账重放:持久化停用的正主 ──────────────────────────────────────────── // OC 会从 models.dev 重新拉取并整体覆盖 kv catalog(2026-10-19 18:05 实测: // updatedAt 被刷新、xiaomi 9 模型全部回潮)。本函数按 // ① cfg 的「以下是停用列表」(provider 级,通配)② modelID 级镜像 // 把回潮的停用项重新压掉,写完再 finishBatch 触发 TUI 热重载。 let recLastTime = -1 let recTicks = 0 async function reconcile(ctx: any): Promise { ensureCfg() const stopList = readStopList() const mirror = loadMirror() const hasMirror = Object.values(mirror).some((ids) => ids.length > 0) if (stopList.length === 0 && !hasMirror) return recTicks++ const t = await readCatalogTime() const force = recTicks % 12 === 0 // 每 ~60s 强制全量对账一次,兜底 time_updated 不可靠 if (!force && t === recLastTime && t !== 0) return // catalog 没被写过 → 不花 6.5MB 解析 recLastTime = t const cat = await readCatalog() if (!cat || !cat.body) return let body: any try { body = typeof cat.body === "string" ? JSON.parse(cat.body) : cat.body } catch { return } const plan: Array<{ op: string; payload: any }> = [] for (const id of Object.keys(body)) { const p = body[id] || {} const name = p.name || "" const models = p.models || {} if (stopList.length && stopListHits(stopList, id, name) && Object.keys(models).length) { plan.push({ op: "disable_all", payload: { provider: id } }) } const ids = mirror[id] if (ids && ids.length) { for (const mid of ids) { if (models[mid]) plan.push({ op: "disable", payload: { provider: id, modelID: mid } }) } } } if (plan.length === 0) return fileLog(`回潮检测:OC 刷新 catalog 使 ${plan.length} 项停用失效,开始重放压制(cfg=${stopList.length} 项 provider 级 + 镜像 modelID 级)`) for (const step of plan) { const r = await execDbWrite(step.op, step.payload) fileLog(`重放 ${step.op} ${JSON.stringify(step.payload)}: ${r}`) } recLastTime = await readCatalogTime() await finishBatch(ctx, "reconcile") } 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 启动") // ctx 形态诊断(一次性):回答「为什么进程内 reload 无通道」—— // 2026-10-19 实测 keys 里没有 client/serverUrl/location,热重载只能靠 TUI 侧 try { const c: any = ctx fileLog( "setup ctx 诊断: keys=" + JSON.stringify(Object.keys(c || {})) + " | client=" + typeof (c && c.client) + " | location=" + typeof (c && c.location) + " | serverUrl=" + typeof (c && c.serverUrl) + " | app=" + typeof (c && c.app), ) } catch { /* 诊断失败不影响功能 */ } ensureDbExec() ensureCfg() // 防御:OC 更新删掉 cfg 时立即释放新配置(含停用列表镜像恢复) // 后台 daemon:每 5s 轮询 outbox(普通文件读写,不碰 DB 锁)+ 对账压制回潮 let stopped = false const daemon = setInterval(async () => { if (stopped) return try { await processOutbox(ctx) } catch (e: any) { fileLog("daemon tick 异常(已吞掉): " + (e && e.message ? e.message : String(e))) } try { await reconcile(ctx) } catch (e: any) { fileLog("reconcile 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 disableAll(provider: string) { const line = JSON.stringify({ op: "disable_all", payload: { provider }, at: Date.now() }) appendFileSync(OUTBOX, line + "\n") fileLog("disable_all 入队 " + provider) }, 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 }, }