diff --git a/tui-plugins/oc-session-all.js b/tui-plugins/oc-session-all.js index 4456737..aecf0e3 100644 --- a/tui-plugins/oc-session-all.js +++ b/tui-plugins/oc-session-all.js @@ -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 {