tui.js:/manage 精确匹配替换前缀匹配,选厂后加单模型/整厂粒度层

This commit is contained in:
ypshidifu
2026-10-19 12:34:02 +08:00
parent 38c2b20e3d
commit 7ab1b9a9b0
+95 -32
View File
@@ -427,13 +427,14 @@ function mmReadCatalog() {
try {
const py = process.platform === "win32" ? "python" : "python3"
// 读 catalog + credential(credential 表存 /connect 传 key 的内置 provider,如 xiaomi)
// label = /connect 时选的 service 名(如 "Xiaomi"),用于精确区分同 integration 的不同 service
// 坑:① Python -c 单行里 if row: import sys 非法 ② Windows GBK 编码 → 全走 buffer.write utf-8
const script =
"import sqlite3,os,sys,json;" +
"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();" +
"creds=[r[0] for r in con.execute(\"SELECT integration_id FROM credential WHERE active=1\").fetchall()];" +
"creds=[{'i':r[0],'l':r[1]} for r in con.execute(\"SELECT integration_id,label FROM credential WHERE active=1\").fetchall()];" +
"con.close();" +
"obj={'catalog':(row[0] if row else None),'credentials':creds};" +
"sys.stdout.buffer.write(json.dumps(obj, ensure_ascii=False).encode('utf-8'))"
@@ -476,10 +477,12 @@ async function openModelManager(context) {
// —— catalog 全量 232 个不是已连接,要按 OC 当前实际启用集合过滤
let connectedProviders = []
const seen = new Set()
const addProvider = (name, models, source) => {
if (!name || seen.has(name)) return
seen.add(name)
connectedProviders.push({ name, models: models || [], source })
// id = providerID(catalog key / config key,daemon 端 body.get(provider) 用它索引)
// name = 显示名(cv.name,如 "Xiaomi"),只用于界面展示
const addProvider = (id, name, models, source) => {
if (!id || seen.has(id)) return
seen.add(id)
connectedProviders.push({ id, name: name || id, models: models || [], source })
}
// 来源 A:config provider
@@ -492,7 +495,7 @@ async function openModelManager(context) {
const pv = prov[pid] || {}
const name = pv.name || pid
const models = Object.keys((pv.models && pv.models) || {})
addProvider(name, models, "config")
addProvider(pid, name, models, "config")
}
}
log("[oc-session-all] /manage 来源A(config): " + connectedProviders.map((p) => p.name).join(", "))
@@ -501,9 +504,10 @@ async function openModelManager(context) {
}
// 来源 B:catalog 里「已连接内置 provider」。
// credential 表存的是"集成名"(如 xiaomi),catalog 存的是"具体 providerID"(xiaomi、
// xiaomi-token-plan-cn、xiaomi-token-plan-sgp、xiaomi-token-plan-ams 等)。
// 匹配规则:catalog providerID 以 credential.integration_id 开头(前缀匹配)就算已连接。
// credential 表:integration_id=集成名(如 xiaomi),label=/connect 时选的 service 名(如 "Xiaomi")。
// ★ 精确匹配规则(前缀匹配已废弃——它会把没订阅的 Xiaomi Token Plan * 变体也拖进来):
// ① label 精确等于 provider 显示名 → 就是 /connect 选的那个 service
// ② 匹配不到再回退 cid === integration_id(label 缺失/被改名的兜底)
const cat = await mmReadCatalog()
if (cat && cat.catalog && cat.credentials) {
let body = {}
@@ -524,23 +528,33 @@ async function openModelManager(context) {
log("[oc-session-all] /manage credential 表无 active=1 记录(没 /connect 过内置 provider)")
}
const hits = []
for (const cid of Object.keys(body)) {
const cv = body[cid] || {}
const models = Object.keys((cv && cv.models) || {})
if (models.length === 0) continue
// 前缀匹配:providerID 以某个 credential.integration_id 开头
const isConnected = credentials.some((integ) => cid === integ || cid.startsWith(integ + "-") || cid.startsWith(integ + "_"))
if (!isConnected) continue
const name = cv.name || cid
const hitSet = new Set()
for (const c of credentials) {
const integ = (c && c.i) || ""
const label = (c && c.l) || ""
let matched = label
? Object.keys(body).filter((cid) => (((body[cid] || {}).name) || cid) === label)
: []
if (matched.length === 0 && integ) {
matched = Object.keys(body).filter((cid) => cid === integ)
}
log("[oc-session-all] /manage 来源B credential=" + JSON.stringify(c) + " → " + JSON.stringify(matched))
for (const cid of matched) {
if (hitSet.has(cid)) continue
hitSet.add(cid)
hits.push(cid)
addProvider(name, models, "catalog")
}
}
for (const cid of hits) {
const cv = body[cid] || {}
addProvider(cid, cv.name || cid, Object.keys(cv.models || {}), "catalog")
}
log("[oc-session-all] /manage 来源B 命中: " + JSON.stringify(hits))
} else {
log("[oc-session-all] /manage 来源B 跳过: cat=" + (cat ? "有值但catalog/credentials缺一" : "mmReadCatalog 返回 null"))
}
log("[oc-session-all] /manage 最终列表(" + connectedProviders.length + "): " + JSON.stringify(connectedProviders.map((p) => ({ n: p.name, m: p.models.length, s: p.source }))))
log("[oc-session-all] /manage 最终列表(" + connectedProviders.length + "): " + JSON.stringify(connectedProviders.map((p) => ({ id: p.id, n: p.name, m: p.models.length, s: p.source }))))
if (connectedProviders.length === 0) {
context.ui.toast.show({
message: "没有已连接 provider(config 和 catalog 都为空),先 /connect 一个再 /manage",
@@ -550,6 +564,7 @@ async function openModelManager(context) {
}
// 第一层:选 provider(顶置「恢复出厂」全局操作)
// 注意:OC dialog.select 的 options 字段是 title/value(不是 label/value,label 会 undefined→崩溃)
// value 用 providerID(daemon 端 body.get(provider) 按 catalog key 索引,显示名 "Xiaomi"≠key "xiaomi")
const providerOptions = [
{
title: "🏭 恢复出厂(全部已连接 provider)",
@@ -557,7 +572,7 @@ async function openModelManager(context) {
},
...connectedProviders.map((p) => ({
title: p.name + "(" + p.models.length + " 个模型 · " + p.source + ")",
value: p.name,
value: p.id,
})),
]
const pickProvider = await context.ui.dialog.select({
@@ -567,27 +582,77 @@ async function openModelManager(context) {
})
if (!pickProvider) return
if (pickProvider === "__reset_all__") {
for (const p of connectedProviders) mmEnqueue("reset", { provider: p.name })
for (const p of connectedProviders) mmEnqueue("reset", { provider: p.id })
context.ui.toast.show({
message: "恢复出厂已入队(" + connectedProviders.length + " 个 provider),等 daemon 消费后 /models 生效",
variant: "info",
})
return
}
// 该 provider 的当前 models
const target = connectedProviders.find((p) => p.name === pickProvider)
const models = (target && target.models) || []
const target = connectedProviders.find((p) => p.id === pickProvider)
if (!target) {
context.ui.toast.show({ message: "未找到该 provider", variant: "warning" })
return
}
const dispName = target.name
const models = target.models || []
// 第二层:管理粒度 —— 单个模型 vs 整个厂商
const scope = await context.ui.dialog.select({
title: "模型管理 · " + dispName + " · 选择粒度",
placeholder: "管单个模型,还是管整个厂商?",
options: [
{ title: "🧩 单个模型(停用/启用其中一个)", value: "model" },
{ title: "🏭 整个厂商(全部停用 / 全部启用)", value: "provider" },
],
})
if (!scope) return
if (scope === "provider") {
// 厂商粒度:停用=逐个下架现存模型;启用=恢复该厂商全部模型(reset 拉回 pristine)
const pAct = await context.ui.dialog.select({
title: "对整个 " + dispName + " 执行",
placeholder: "停用整个厂商,还是恢复全部模型?",
options: [
{ title: "⛔ 停用整个厂商(全部模型下架)", value: "disable_all" },
{ title: "✅ 启用整个厂商(恢复全部模型)", value: "enable_all" },
],
})
if (!pAct) return
if (pAct === "disable_all") {
if (models.length === 0) {
context.ui.toast.show({ message: pickProvider + " 下没有模型", variant: "warning" })
context.ui.toast.show({ message: dispName + " 的模型已全部停用", variant: "warning" })
return
}
for (const m of models) mmEnqueue("disable", { provider: target.id, modelID: m })
context.ui.toast.show({
message: "已入队:停用整个 " + dispName + "(" + models.length + " 个模型),daemon 5s 内消费",
variant: "info",
})
} else {
mmEnqueue("reset", { provider: target.id })
context.ui.toast.show({
message: "已入队:启用整个 " + dispName + "(恢复全部模型),daemon 5s 内消费",
variant: "info",
})
}
return
}
// scope === "model":单模型粒度
if (models.length === 0) {
context.ui.toast.show({
message: dispName + " 下没有可管理的模型(已全部停用?可用「整个厂商 → 启用」恢复)",
variant: "warning",
})
return
}
// 第二层:逐模型选 停用/启用
const modelOptions = models.map((m) => ({
title: m,
value: m,
}))
const pickModel = await context.ui.dialog.select({
title: "模型管理 · " + pickProvider,
title: "模型管理 · " + dispName + " · 选模型",
placeholder: "选一个模型 停用/启用…",
options: modelOptions,
})
@@ -595,18 +660,16 @@ async function openModelManager(context) {
const actOptions = [
{ title: "⛔ 停用(不在 /models 显示)", value: "disable" },
{ title: "✅ 启用(恢复显示)", value: "enable" },
{ title: "🏭 恢复该 provider 出厂", value: "reset" },
]
const action = await context.ui.dialog.select({
title: "对 " + pickModel + " 执行",
options: actOptions,
})
if (!action) return
if (action === "disable") mmEnqueue("disable", { provider: pickProvider, modelID: pickModel })
else if (action === "enable") mmEnqueue("enable", { provider: pickProvider, modelID: pickModel })
else mmEnqueue("reset", { provider: pickProvider })
if (action === "disable") mmEnqueue("disable", { provider: target.id, modelID: pickModel })
else mmEnqueue("enable", { provider: target.id, modelID: pickModel })
context.ui.toast.show({
message: "已入队:" + action + " " + pickProvider + "/" + pickModel + "(daemon 5s 内消费,/models 将更新)",
message: "已入队:" + action + " " + dispName + "/" + pickModel + "(daemon 5s 内消费,/models 将更新)",
variant: "info",
})
} catch (e) {