引进 cfg.json 停用列表(键值不区分大小写、只认一个键)+ 对账重放持久化停用 + 配置丢失自动重建

This commit is contained in:
youpaishidifu
2026-10-19 18:38:40 +08:00
parent 9a77537b29
commit 125d4d099c
2 changed files with 333 additions and 7 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"停用清单": "持久化标记停用模型",
"以下是停用列表": [],
"配置格式": "一行一个,比如Xiaomi,deepseek等,*表示通配符,比如xiaomi-*则表示停用小米家族的全部模型(Xiaomi、Xiaomi-token-plan等)",
"示例参数":[
"xiaomi",
"DEEPSEEK",
"longcat-*"
]
}
+323 -7
View File
@@ -55,8 +55,29 @@ const OUTBOX = join(homedir(), ".config", "opencode", "model-manager.outbox.json
// 出厂备份(reset 用:首次操作前自动把 pristine catalog 存一份)
const PRISTINE = join(homedir(), ".config", "opencode", "model-manager.pristine.json")
// flush 标记:daemon 把队列排空并写完 DB 后落盘 { at, lastOp, serverReload };
// tui.js 轮询到 at >= 自己入队时刻 就执行 location.reload(TUI 侧才有 reload 通道)
// 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 写锁)
@@ -73,6 +94,155 @@ function fileLog(msg: string) {
}
}
// ── 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<string, string[]>
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<any | null> {
try {
@@ -219,17 +389,59 @@ async function processOutbox(ctx: any): Promise<void> {
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) {
// 还有排队的操作(整场停用会连着 9 条):先不 reload,等全部写完一次性热重载
// 还有排队的操作(如逐模型停用多条):先不 reload,等全部写完一次性热重载
fileLog("队列未排空,暂不 reload(等最后一条写完再一次性热重载)")
return
}
// 队列已排空:先试进程内通道(V2 server ctx 实测无通道,失败会写日志),
// 再落 flush 标记 —— TUI 侧 tui.js 轮询到标记后用它自己的 client.location 热重载
await finishBatch(ctx, op)
}
// 队列排空后的收尾:进程内 reload 尝试 + 落 flush 标记(TUI 常驻 poller 据此热重载)
async function finishBatch(ctx: any, tag: string): Promise<void> {
const serverReload = await doReload(ctx)
try {
writeFileSync(FLUSH, JSON.stringify({ at: Date.now(), lastOp: op, serverReload: !!serverReload }))
fileLog("flush 标记已写(TUI 侧将据此触发热重载): serverReload=" + !!serverReload)
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)))
}
@@ -294,6 +506,21 @@ if op == "disable":
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()
@@ -342,6 +569,84 @@ function ensureDbExec(): void {
}
}
// 读 catalog 的 time_updated(单值,便宜;用于判断 OC 是否刷新覆盖过 catalog)
async function readCatalogTime(): Promise<number> {
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<void> {
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 {
@@ -365,8 +670,9 @@ export default {
/* 诊断失败不影响功能 */
}
ensureDbExec()
ensureCfg() // 防御:OC 更新删掉 cfg 时立即释放新配置(含停用列表镜像恢复)
// 后台 daemon:每 5s 轮询 outbox(普通文件读写,不碰 DB 锁)
// 后台 daemon:每 5s 轮询 outbox(普通文件读写,不碰 DB 锁)+ 对账压制回潮
let stopped = false
const daemon = setInterval(async () => {
if (stopped) return
@@ -375,6 +681,11 @@ export default {
} 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,便于外部清理
@@ -385,6 +696,11 @@ export default {
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")