Files
oc_session/model-manager.ts
T

361 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
},
}