diff --git a/core/internal/buildinfo/buildinfo.go b/core/internal/buildinfo/buildinfo.go index f60bc3c4..54c2dfdd 100644 --- a/core/internal/buildinfo/buildinfo.go +++ b/core/internal/buildinfo/buildinfo.go @@ -4,7 +4,7 @@ import "strings" // Set at link time via -ldflags (see .goreleaser.yaml). var ( - Version = "dev0.1.42" + Version = "dev0.1.43" Commit = "none" Date = "unknown" ) diff --git a/electron/clovapi-desktop.js b/electron/clovapi-desktop.js index c73e3f8e..e685dbee 100644 --- a/electron/clovapi-desktop.js +++ b/electron/clovapi-desktop.js @@ -64,6 +64,29 @@ function saveProfiles(payload) { }); } +async function switchProviderModel(cliKind, providerId, modelId) { + const result = await runClovapiArgsAsync( + [ + "switch", + "--cli", + String(cliKind || ""), + "--provider", + String(providerId || ""), + "--model", + String(modelId || ""), + ], + { timeout: 45000 }, + ); + if (result.error && result.error.code === "ETIMEDOUT") { + return { ok: false, error: "clovapi switch timed out" }; + } + if (!result.ok) { + const message = String(result.stderr || result.stdout || "clovapi switch failed").trim(); + return { ok: false, error: message || "clovapi switch failed" }; + } + return { ok: true, stdout: result.stdout, stderr: result.stderr }; +} + function listVendorModels(vendorName) { return runDesktop(["vendor", "list-models", "--vendor", String(vendorName || "")], { timeout: 45000, @@ -122,6 +145,7 @@ module.exports = { loadProxyConfig, saveProxyConfig, saveProfiles, + switchProviderModel, listVendorModels, testBinding, modelAdapters, diff --git a/electron/main.js b/electron/main.js index 82d6da08..6ad7c4fd 100644 --- a/electron/main.js +++ b/electron/main.js @@ -6,6 +6,7 @@ const { createGoProxyManager } = require("./proxy-manager"); const proxyLogger = require("./proxy-logger"); const callLogsStore = require("./call-logs-store"); const clovapiDesktop = require("./clovapi-desktop"); +const { applyTrayModelSwitch } = require("./tray-model-switch"); const { coreDevStatePath, resolveClovapiExecutable: resolveBundledClovapiExecutable, @@ -232,31 +233,15 @@ async function readTrayDesktopState() { } async function switchTrayAgentModel(cliKind, providerId, modelId) { - const kind = String(cliKind || "").trim(); - const provider = String(providerId || "").trim(); - const model = String(modelId || "").trim(); - if (!kind || !provider || !model) return; - - const loaded = await clovapiDesktop.loadProfiles(); - if (!loaded?.ok) { - emitOutput("stderr", `[tray] failed to load profiles: ${loaded?.error || "unknown error"}\n`); - return; - } - - const active = loaded.active && typeof loaded.active === "object" ? { ...loaded.active } : {}; - active[kind] = { provider_id: provider, model_id: model }; - const saved = await clovapiDesktop.saveProfiles({ - profiles: Array.isArray(loaded.profiles) ? loaded.profiles : [], - active, - proxy: loaded.proxy, + await applyTrayModelSwitch({ + desktop: clovapiDesktop, + cliKind, + providerId, + modelId, + emitOutput, + dispatchRendererEvent, + updateTrayMenu, }); - if (!saved?.ok) { - emitOutput("stderr", `[tray] failed to switch ${kind} model: ${saved?.error || "unknown error"}\n`); - return; - } - - dispatchRendererEvent({ type: "profiles-changed" }); - await updateTrayMenu(); } async function updateTrayMenu() { diff --git a/electron/package.json b/electron/package.json index 51a867d1..7d1d8285 100644 --- a/electron/package.json +++ b/electron/package.json @@ -13,7 +13,7 @@ "build:icons": "node scripts/build-icons.mjs", "build:mac": "npm run build:ui && npm run build:icons && electron-builder --mac dmg", "build:win": "npm run build:ui && electron-builder --win nsis", - "test": "node --test proxy-manager.test.js clovapi-exec.test.js tray-menu.test.js desktop-update.test.js", + "test": "node --test proxy-manager.test.js clovapi-exec.test.js tray-menu.test.js tray-model-switch.test.js desktop-update.test.js", "start": "npm run build:ui && electron ." }, "devDependencies": { diff --git a/electron/proxy-manager.js b/electron/proxy-manager.js index 150e5932..b6db66ab 100644 --- a/electron/proxy-manager.js +++ b/electron/proxy-manager.js @@ -617,6 +617,7 @@ module.exports = { buildProxyStopArgs, normalizeBindHost, healthClientHost, + reachableLoopbackHost, healthUrl, redactSecrets, createGoProxyManager, diff --git a/electron/tray-model-switch.js b/electron/tray-model-switch.js new file mode 100644 index 00000000..3c7aaf59 --- /dev/null +++ b/electron/tray-model-switch.js @@ -0,0 +1,32 @@ +async function applyTrayModelSwitch(options = {}) { + const desktop = options.desktop; + const emitOutput = typeof options.emitOutput === "function" ? options.emitOutput : () => {}; + const dispatchRendererEvent = + typeof options.dispatchRendererEvent === "function" ? options.dispatchRendererEvent : () => {}; + const updateTrayMenu = typeof options.updateTrayMenu === "function" ? options.updateTrayMenu : async () => {}; + + const kind = String(options.cliKind || "").trim(); + const provider = String(options.providerId || "").trim(); + const model = String(options.modelId || "").trim(); + if (!kind || !provider || !model) return { ok: false, skipped: true }; + if (!desktop || typeof desktop.switchProviderModel !== "function") { + emitOutput("stderr", `[tray] failed to switch ${kind} model: clovapi switch is unavailable\n`); + return { ok: false, error: "clovapi switch is unavailable" }; + } + + const result = await desktop.switchProviderModel(kind, provider, model); + if (!result?.ok) { + const error = String(result?.error || "unknown error").trim() || "unknown error"; + emitOutput("stderr", `[tray] failed to switch ${kind} model: ${error}\n`); + await updateTrayMenu(); + return { ok: false, error }; + } + + dispatchRendererEvent({ type: "profiles-changed" }); + await updateTrayMenu(); + return { ok: true }; +} + +module.exports = { + applyTrayModelSwitch, +}; diff --git a/electron/tray-model-switch.test.js b/electron/tray-model-switch.test.js new file mode 100644 index 00000000..91d5545b --- /dev/null +++ b/electron/tray-model-switch.test.js @@ -0,0 +1,60 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { applyTrayModelSwitch } = require("./tray-model-switch"); + +test("applyTrayModelSwitch delegates tray selections to clovapi switch", async () => { + const calls = []; + const result = await applyTrayModelSwitch({ + desktop: { + async switchProviderModel(cliKind, providerId, modelId) { + calls.push({ cliKind, providerId, modelId }); + return { ok: true }; + }, + async saveProfiles() { + throw new Error("tray switch must not save profiles directly"); + }, + }, + cliKind: "codex", + providerId: "custom-api", + modelId: "gpt-5.5", + dispatchRendererEvent(payload) { + calls.push({ event: payload }); + }, + async updateTrayMenu() { + calls.push({ updateTrayMenu: true }); + }, + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, [ + { cliKind: "codex", providerId: "custom-api", modelId: "gpt-5.5" }, + { event: { type: "profiles-changed" } }, + { updateTrayMenu: true }, + ]); +}); + +test("applyTrayModelSwitch reports switch failures without profile change events", async () => { + const errors = []; + const events = []; + const result = await applyTrayModelSwitch({ + desktop: { + async switchProviderModel() { + return { ok: false, error: "write failed" }; + }, + }, + cliKind: "hermes", + providerId: "custom-api", + modelId: "claude-sonnet", + emitOutput(stream, message) { + errors.push({ stream, message }); + }, + dispatchRendererEvent(payload) { + events.push(payload); + }, + }); + + assert.equal(result.ok, false); + assert.equal(result.error, "write failed"); + assert.deepEqual(events, []); + assert.deepEqual(errors, [{ stream: "stderr", message: "[tray] failed to switch hermes model: write failed\n" }]); +});