361 lines
14 KiB
TypeScript
361 lines
14 KiB
TypeScript
// 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")
|
||
|
||
// 热重载通道见 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 {
|
||
/* 日志失败绝不阻塞 */
|
||
}
|
||
}
|
||
|
||
// 读 catalog(只读 SQLite,OC 自身持锁但只读不冲突;用 Python 子进程读保证一致)
|
||
async function readCatalog(): Promise<any | null> {
|
||
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<string> {
|
||
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 变更)
|
||
// 通道整段抄自 ai-mcp 的 doReload(MCP 对调已验证此链路可用):
|
||
// 进程内 SDK client.location.reload 三种调用形态 → HTTP POST ctx.serverUrl /api/location/reload
|
||
// 关键差异:用 ctx.serverUrl(OC 自己告知插件的服务地址),绝不写死端口
|
||
// 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<boolean> {
|
||
const attempts: Array<() => Promise<any>> = []
|
||
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 通道(ctx 无 location/serverUrl)"
|
||
fileLog("hot reload 失败(需重启 OC 生效): " + msg)
|
||
return false
|
||
}
|
||
|
||
// 从 outbox 消费一条操作并执行
|
||
async function processOutbox(ctx: any): Promise<void> {
|
||
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 doReload(ctx)
|
||
}
|
||
|
||
// 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 == "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)))
|
||
}
|
||
}
|
||
|
||
export const ModelManager: any = async (_ctx: any) => (await ({} as any), undefined as any)
|
||
|
||
export default {
|
||
id: "model-manager",
|
||
async setup(ctx: any): Promise<void> {
|
||
try {
|
||
fileLog("model-manager setup 启动")
|
||
ensureDbExec()
|
||
|
||
// 后台 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)))
|
||
}
|
||
}, 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
|
||
},
|
||
}
|