tui.js:模型管理面板改状态机,每层带「← 返回上级」;恢复出厂设置=删除厂商并二次确认

This commit is contained in:
ypshidifu
2026-10-19 22:29:27 +08:00
parent cd25c0a3d0
commit 874e69fb14
+356 -203
View File
@@ -411,6 +411,49 @@ function toastResult(context, res, actionLabel) {
// 而 TUI 插件 ctx 的 client.location 一直可用(本日志大量 location.reload OK)。
const MM_OUTBOX = join(homedir(), ".config", "opencode", "model-manager.outbox.jsonl")
const MM_FLUSH = join(homedir(), ".config", "opencode", "model-manager.last-flush.json")
const MM_CFG = join(homedir(), ".config", "opencode", "model-manager.cfg.json")
const MM_REMOVED = join(homedir(), ".config", "opencode", "model-manager.removed.json")
const MM_PRISTINE = join(homedir(), ".config", "opencode", "model-manager.pristine.json")
// 读 cfg.json 的「以下是停用列表」(键名大小写不敏感,其它键全部忽略)
function mmReadStopList() {
try {
if (!existsSync(MM_CFG)) return []
const j = JSON.parse(readFileSync(MM_CFG, "utf-8"))
const target = "以下是停用列表".toLowerCase()
for (const k of Object.keys(j || {})) {
if (String(k).toLowerCase() === target) {
const v = j[k]
if (Array.isArray(v)) return v.filter((x) => typeof x === "string" && x.trim())
}
}
return []
} catch {
return []
}
}
// 读「已彻底删除」的厂商列表(remove_provider 标记,供面板显示与找回)
function mmReadRemovedList() {
try {
if (!existsSync(MM_REMOVED)) return []
const j = JSON.parse(readFileSync(MM_REMOVED, "utf-8"))
return Array.isArray(j) ? j.filter((x) => typeof x === "string") : []
} catch {
return []
}
}
// 读 pristine(出厂 catalog)→ 用于显示「被停用/删除的厂商」还能找回多少模型
function mmReadPristineBody() {
try {
if (!existsSync(MM_PRISTINE)) return {}
const j = JSON.parse(readFileSync(MM_PRISTINE, "utf-8"))
const b = j && j.body
if (typeof b === "string") return JSON.parse(b)
return b && typeof b === "object" ? b : {}
} catch {
return {}
}
}
function mmEnqueue(op, payload) {
try {
@@ -521,220 +564,330 @@ function mmStartFlushPoller(context) {
}, 2000)
}
// ── /manage 模型管理面板(状态机:每层都有「← 返回上级」,不靠 ESC)────────
// 说明:OC 原生 dialog 的 ESC 直接关整个面板,无法逐层回退,
// 所以每层菜单第一项固定是「← 返回上级」,由我们自己维护层级栈。
const MM_BACK = "__mm_back__"
const MM_CLOSE = "__mm_close__"
// 收集「已连接 provider」(config provider + catalog∧credential 精确匹配)
async function mmCollectProviders() {
const connectedProviders = []
const seen = new Set()
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:opencode.json 的 provider 字段(cc-switch 自定义加的,如 id=1)
try {
const cfgPath = join(homedir(), ".config", "opencode", "opencode.json")
if (existsSync(cfgPath)) {
const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"))
const prov = (cfg && cfg.provider) || {}
for (const pid of Object.keys(prov)) {
const pv = prov[pid] || {}
addProvider(pid, pv.name || pid, Object.keys((pv.models && pv.models) || {}), "config")
}
}
} catch (e) {
log("[oc-session-all] /manage 读 config provider 失败: " + e.message)
}
// 来源 B:catalog 里「已连接内置 provider」。
// ★ 精确匹配(前缀匹配已废弃——会把没订阅的 Xiaomi Token Plan * 变体也拖进来):
// ① credential.label 精确等于 provider 显示名 ② 回退 cid === integration_id
const cat = await mmReadCatalog()
if (cat && cat.catalog && cat.credentials) {
let body = {}
try {
// 坑:models-dev:catalog 的 value 是外层包装 {updatedAt, digest, body},
// body 才是真正的 provider JSON 字符串 —— 必须二次解包
const outer = JSON.parse(cat.catalog)
if (outer && typeof outer.body === "string") body = JSON.parse(outer.body)
else if (outer && outer.body && typeof outer.body === "object") body =outer.body
else body = outer
} catch (e) {
log("[oc-session-all] /manage catalog body parse 失败: " + e.message)
}
const credentials = Array.isArray(cat.credentials) ? cat.credentials : []
const hits = []
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)
for (const cid of matched) {
if (hitSet.has(cid)) continue
hitSet.add(cid)
hits.push(cid)
}
}
for (const cid of hits) {
const cv = body[cid] || {}
addProvider(cid, cv.name || cid, Object.keys(cv.models || {}), "catalog")
}
log("[oc-session-all] /manage catalog 命中: " + JSON.stringify(hits))
}
return connectedProviders
}
// 已停用/已删除的厂商(可找回):cfg 停用列表 + 已删除列表,去掉仍在线的
function mmFindDisabled(connected) {
const online = new Set(connected.map((p) => p.id))
const stop = mmReadStopList()
const removed = mmReadRemovedList()
const pristine = mmReadPristineBody()
const ids = []
for (const id of stop.concat(removed)) {
if (!id || online.has(id) || ids.includes(id)) continue
ids.push(id)
}
return ids.map((id) => {
const p = pristine[id] || {}
return {
id,
name: p.name || id,
models: Object.keys(p.models || {}),
deleted: removed.includes(id),
}
})
}
async function openModelManager(context) {
try {
// ── 收集「已连接 provider」,两个来源都读,合并去重 ──────────────────
// 来源 A:opencode.json 的 provider 字段(cc-switch 自定义加的,如 id=1)
// 来源 B:opencode.db catalog 里「当前 OC 真正启用的内置 provider」(如 Xiaomi)
// —— catalog 全量 232 个不是已连接,要按 OC 当前实际启用集合过滤
let connectedProviders = []
const seen = new Set()
// 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 })
}
// 层级状态机:view = list | scope | provider | model | action | find
let view = "list"
let target = null // 当前厂商 {id,name,models}
let pendingModel = null
// 来源 A:config provider
try {
const cfgPath = join(homedir(), ".config", "opencode", "opencode.json")
if (existsSync(cfgPath)) {
const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"))
const prov = (cfg && cfg.provider) || {}
for (const pid of Object.keys(prov)) {
const pv = prov[pid] || {}
const name = pv.name || pid
const models = Object.keys((pv.models && pv.models) || {})
addProvider(pid, name, models, "config")
for (;;) {
// ── 第 1 层:选厂商 ────────────────────────────────────────────────
if (view === "list") {
const connectedProviders = await mmCollectProviders()
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 }))),
)
const disabled = mmFindDisabled(connectedProviders)
const options = [
{ title: "✕ 关闭面板", value: MM_CLOSE },
...connectedProviders.map((p) => ({
title: p.name + "(" + p.models.length + " 个模型 · " + p.source + ")",
value: p.id,
})),
]
if (disabled.length) {
options.push({ title: "♻️ 已停用/已删除的厂商(" + disabled.length + ")", value: "__find__" })
}
}
log("[oc-session-all] /manage 来源A(config): " + connectedProviders.map((p) => p.name).join(", "))
} catch (e) {
log("[oc-session-all] /manage 读 config provider 失败: " + e.message)
}
// 来源 B:catalog 里「已连接内置 provider」。
// 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 = {}
try {
// 坑:models-dev:catalog 的 value 是外层包装 {updatedAt, digest, body},
// body 才是真正的 provider JSON 字符串(provider→models)——必须二次解包,
// 否则 Object.keys 只有 3 个(updatedAt/digest/body),循环命不中任何 provider。
const outer = JSON.parse(cat.catalog)
if (outer && typeof outer.body === "string") body = JSON.parse(outer.body)
else if (outer && outer.body && typeof outer.body === "object") body = outer.body
else body = outer
} catch (e) {
log("[oc-session-all] /manage catalog body parse 失败: " + e.message)
}
const credentials = Array.isArray(cat.credentials) ? cat.credentials : []
log("[oc-session-all] /manage 来源B: credentials=" + JSON.stringify(credentials) + " catalogProviders=" + Object.keys(body).length)
if (credentials.length === 0) {
log("[oc-session-all] /manage credential 表无 active=1 记录(没 /connect 过内置 provider)")
}
const hits = []
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)
}
}
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) => ({ 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",
variant: "warning",
})
return
}
// 第一层:选 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)",
value: "__reset_all__",
},
...connectedProviders.map((p) => ({
title: p.name + "(" + p.models.length + " 个模型 · " + p.source + ")",
value: p.id,
})),
]
const pickProvider = await context.ui.dialog.select({
title: "模型管理 · 选已连接 provider",
placeholder: "选一个已连接 provider 管理其模型…",
options: providerOptions,
})
if (!pickProvider) return
if (pickProvider === "__reset_all__") {
for (const p of connectedProviders) mmEnqueue("reset", { provider: p.id })
context.ui.toast.show({
message: "恢复出厂已入队(" + connectedProviders.length + " 个 provider),daemon 写完自动热重载,/models 随即生效",
variant: "info",
})
return
}
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 整个厂商 vs 恢复出厂(格式化)
const scope = await context.ui.dialog.select({
title: "模型管理 · " + dispName + " · 选择粒度",
placeholder: "管单个模型、整个厂商,还是格式化恢复出厂?",
options: [
{ title: "🧩 单个模型(停用/启用其中一个)", value: "model" },
{ title: "🏭 整个厂商(全部停用 / 全部启用)", value: "provider" },
{ title: "♻️ 恢复出厂(格式化该厂商 → 出厂模型集)", value: "factory" },
],
})
if (!scope) return
if (scope === "factory") {
// 格式化 = 整厂重置回出厂 pristine(含把已停用的全部拉回),并从 cfg 停用列表摘除
mmEnqueue("reset", { provider: target.id })
context.ui.toast.show({
message: "已入队:格式化 " + dispName + "(恢复出厂模型集),daemon 写完自动热重载",
variant: "info",
})
return
}
if (scope === "provider") {
// 厂商粒度:停用=整厂一条 disable_all 下架(持久化进 cfg 停用列表);启用=reset 拉回 pristine
const pAct = await context.ui.dialog.select({
title: "对整个 " + dispName + " 执行",
placeholder: "停用整个厂商,还是恢复全部模型?",
options: [
{ title: "⛔ 停用整个厂商(全部模型下架 · 持久化进 cfg 停用列表)", value: "disable_all" },
{ title: "✅ 启用整个厂商(恢复全部模型)", value: "enable_all" },
],
})
if (!pAct) return
if (pAct === "disable_all") {
if (models.length === 0) {
context.ui.toast.show({ message: dispName + " 的模型已全部停用", variant: "warning" })
if (connectedProviders.length === 0 && disabled.length === 0) {
context.ui.toast.show({
message: "没有已连接 provider(config 和 catalog 都为空),先 /connect 一个再 /manage",
variant: "warning",
})
return
}
mmEnqueue("disable_all", { provider: target.id })
context.ui.toast.show({
message: "已入队:停用整个 " + dispName + "(" + models.length + " 个模型 · 一条搞定 · 持久化),daemon 写完自动热重载",
variant: "info",
})
} else {
mmEnqueue("reset", { provider: target.id })
context.ui.toast.show({
message: "已入队:启用整个 " + dispName + "(恢复全部模型),daemon 写完自动热重载",
variant: "info",
const pick = await context.ui.dialog.select({
title: "模型管理 · 选厂商",
placeholder: "选一个厂商管理其模型…",
options,
})
if (!pick || pick === MM_CLOSE) return
if (pick === "__find__") {
view = "find"
continue
}
const t = connectedProviders.find((p) => p.id === pick)
if (!t) {
context.ui.toast.show({ message: "未找到该 provider", variant: "warning" })
continue
}
target = t
view = "scope"
continue
}
// ── 第 2 层:选粒度(进厂后的第一层,带返回上级)────────────────────
if (view === "scope") {
const dispName = target.name
const pick = await context.ui.dialog.select({
title: "模型管理 · " + dispName + " · 选择操作",
placeholder: "选操作(← 返回上一级)",
options: [
{ title: "← 返回上级(选厂商)", value: MM_BACK },
{ title: "🧩 单个模型(停用/启用其中一个)", value: "model" },
{ title: "🏭 整个厂商(全部停用 / 全部启用)", value: "provider" },
{ title: "♻️ 恢复出厂设置(彻底删除该厂商)", value: "factory" },
],
})
if (!pick || pick === MM_BACK) {
view = "list"
target = null
continue
}
if (pick === "model") {
view = "model"
continue
}
if (pick === "provider") {
view = "provider"
continue
}
if (pick === "factory") {
// 恢复出厂设置 = 彻底删除该厂商(/models 里整厂消失),二次确认
const ok = await context.ui.dialog.select({
title: "确认恢复出厂设置(删除 " + dispName + ")",
placeholder: "该厂商将从 /models 彻底移除(可再找回)",
options: [
{ title: "← 返回上级(不删)", value: MM_BACK },
{ title: "🗑 确定删除 " + dispName, value: "yes" },
],
})
if (!ok || ok === MM_BACK) {
view = "scope"
continue
}
mmEnqueue("remove_provider", { provider: target.id })
context.ui.toast.show({
message: "已入队:删除 " + dispName + "(恢复出厂设置),daemon 写完自动热重载,/models 里该厂消失",
variant: "info",
})
view = "list"
target = null
continue
}
}
// ── 第 3 层:整厂操作 ─────────────────────────────────────────────
if (view === "provider") {
const dispName = target.name
const n = (target.models || []).length
const pick = await context.ui.dialog.select({
title: "对整个 " + dispName + " 执行",
placeholder: "选操作(← 返回上一级)",
options: [
{ title: "← 返回上级", value: MM_BACK },
{ title: "⛔ 停用整个厂商(全部模型下架 · 持久化)", value: "disable_all" },
{ title: "✅ 启用整个厂商(恢复全部模型)", value: "enable_all" },
],
})
if (!pick || pick === MM_BACK) {
view = "scope"
continue
}
if (pick === "disable_all") {
if (n === 0) {
context.ui.toast.show({ message: dispName + " 的模型已全部停用", variant: "warning" })
} else {
mmEnqueue("disable_all", { provider: target.id })
context.ui.toast.show({
message: "已入队:停用整个 " + dispName + "(" + n + " 个模型 · 持久化),daemon 写完自动热重载",
variant: "info",
})
}
} else {
mmEnqueue("reset", { provider: target.id })
context.ui.toast.show({
message: "已入队:启用整个 " + dispName + "(恢复全部模型),daemon 写完自动热重载",
variant: "info",
})
}
view = "list"
target = null
continue
}
// ── 第 4 层:选模型 ───────────────────────────────────────────────
if (view === "model") {
const dispName = target.name
const models = target.models || []
if (models.length === 0) {
context.ui.toast.show({
message: dispName + " 下没有可管理的模型(已全部停用?用「整个厂商 → 启用」恢复)",
variant: "warning",
})
view = "scope"
continue
}
const pick = await context.ui.dialog.select({
title: "模型管理 · " + dispName + " · 选模型",
placeholder: "选一个模型(← 返回上一级)",
options: [
{ title: "← 返回上级", value: MM_BACK },
...models.map((m) => ({ title: m, value: m })),
],
})
if (!pick || pick === MM_BACK) {
view = "scope"
continue
}
pendingModel = pick
view = "action"
continue
}
// ── 第 5 层:对某模型执行 ─────────────────────────────────────────
if (view === "action") {
const pick = await context.ui.dialog.select({
title: "对 " + pendingModel + " 执行",
placeholder: "选操作(← 返回上一级)",
options: [
{ title: "← 返回上级", value: MM_BACK },
{ title: "⛔ 停用(不在 /models 显示)", value: "disable" },
{ title: "✅ 启用(恢复显示)", value: "enable" },
],
})
if (!pick || pick === MM_BACK) {
view = "model"
continue
}
if (pick === "disable") mmEnqueue("disable", { provider: target.id, modelID: pendingModel })
else mmEnqueue("enable", { provider: target.id, modelID: pendingModel })
context.ui.toast.show({
message: "已入队:" + pick + " " + target.name + "/" + pendingModel + ",daemon 写完自动热重载,/models 随即更新",
variant: "info",
})
view = "scope"
continue
}
// ── 找回:已停用/已删除的厂商 ─────────────────────────────────────
if (view === "find") {
const connected = await mmCollectProviders()
const disabled = mmFindDisabled(connected)
if (!disabled.length) {
context.ui.toast.show({ message: "没有已停用/已删除的厂商", variant: "info" })
view = "list"
continue
}
const pick = await context.ui.dialog.select({
title: "已停用/已删除的厂商",
placeholder: "选一个恢复(← 返回上一级)",
options: [
{ title: "← 返回上级", value: MM_BACK },
...disabled.map((d) => ({
title: d.name + "(" + d.models.length + " 个出厂模型 · " + (d.deleted ? "已删除" : "已停用") + ")",
value: d.id,
})),
],
})
if (!pick || pick === MM_BACK) {
view = "list"
continue
}
mmEnqueue("reset", { provider: pick })
context.ui.toast.show({
message: "已入队:恢复 " + pick + "(从出厂集拉回),daemon 写完自动热重载",
variant: "info",
})
view = "list"
continue
}
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: "模型管理 · " + dispName + " · 选模型",
placeholder: "选一个模型 停用/启用…",
options: modelOptions,
})
if (!pickModel) return
const actOptions = [
{ title: "⛔ 停用(不在 /models 显示)", value: "disable" },
{ title: "✅ 启用(恢复显示)", value: "enable" },
]
const action = await context.ui.dialog.select({
title: "对 " + pickModel + " 执行",
options: actOptions,
})
if (!action) return
if (action === "disable") mmEnqueue("disable", { provider: target.id, modelID: pickModel })
else mmEnqueue("enable", { provider: target.id, modelID: pickModel })
context.ui.toast.show({
message: "已入队:" + action + " " + dispName + "/" + pickModel + ",daemon 写完自动热重载,/models 随即更新",
variant: "info",
})
} catch (e) {
log("[oc-session-all] openModelManager ERROR: " + (e.stack || e.message))
try {