diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 66492b976..73cd5b09b 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -13,6 +13,7 @@ import { healthHandlers } from "./server-methods/health.js"; import { logsHandlers } from "./server-methods/logs.js"; import { modelsHandlers } from "./server-methods/models.js"; import { nodeHandlers } from "./server-methods/nodes.js"; +import { providersHandlers } from "./server-methods/providers.js"; import { sendHandlers } from "./server-methods/send.js"; import { sessionsHandlers } from "./server-methods/sessions.js"; import { skillsHandlers } from "./server-methods/skills.js"; @@ -72,6 +73,8 @@ const READ_METHODS = new Set([ "node.list", "node.describe", "chat.history", + "providers.list", + "providers.active", ]); const WRITE_METHODS = new Set([ "send", @@ -138,7 +141,9 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c method === "sessions.patch" || method === "sessions.reset" || method === "sessions.delete" || - method === "sessions.compact" + method === "sessions.compact" || + method === "providers.switch" || + method === "providers.order" ) { return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); } @@ -171,6 +176,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...agentHandlers, ...agentsHandlers, ...browserHandlers, + ...providersHandlers, }; export async function handleGatewayRequest( diff --git a/src/gateway/server-methods/providers.test.ts b/src/gateway/server-methods/providers.test.ts new file mode 100644 index 000000000..9f580d255 --- /dev/null +++ b/src/gateway/server-methods/providers.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { GatewayRequestHandlerOptions } from "./types.js"; +import { providersHandlers } from "./providers.js"; + +function createMockContext() { + return { + deps: { + config: () => ({ agents: { main: { dir: "/mock/agent/dir" } } }), + }, + } as unknown as GatewayRequestHandlerOptions["context"]; +} + +function createMockRespond() { + return vi.fn() as GatewayRequestHandlerOptions["respond"]; +} + +function createMockClient() { + return { + connect: { role: "operator", scopes: ["operator.admin"] }, + } as GatewayRequestHandlerOptions["client"]; +} + +describe("providers.list", () => { + it("returns list of configured providers", async () => { + const respond = createMockRespond(); + const context = createMockContext(); + + await providersHandlers["providers.list"]({ + req: { method: "providers.list", id: "1" }, + params: {}, + client: createMockClient(), + isWebchatConnect: () => false, + respond, + context, + }); + + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + profiles: expect.any(Object), + }), + undefined, + ); + }); +}); + +describe("providers.active", () => { + it("returns the active provider profile", async () => { + const respond = createMockRespond(); + const context = createMockContext(); + + await providersHandlers["providers.active"]({ + req: { method: "providers.active", id: "1" }, + params: {}, + client: createMockClient(), + isWebchatConnect: () => false, + respond, + context, + }); + + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + activeProfile: expect.any(Object), + }), + undefined, + ); + }); +}); + +describe("providers.switch", () => { + it("requires profileId parameter", async () => { + const respond = createMockRespond(); + const context = createMockContext(); + + await providersHandlers["providers.switch"]({ + req: { method: "providers.switch", id: "1" }, + params: {}, + client: createMockClient(), + isWebchatConnect: () => false, + respond, + context, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: expect.stringContaining("profileId"), + }), + ); + }); + + it("switches to specified profile when valid", async () => { + const respond = createMockRespond(); + const context = createMockContext(); + + await providersHandlers["providers.switch"]({ + req: { method: "providers.switch", id: "1" }, + params: { profileId: "anthropic:default" }, + client: createMockClient(), + isWebchatConnect: () => false, + respond, + context, + }); + + expect(respond).toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/server-methods/providers.ts b/src/gateway/server-methods/providers.ts new file mode 100644 index 000000000..8e023fe9d --- /dev/null +++ b/src/gateway/server-methods/providers.ts @@ -0,0 +1,205 @@ +import { + ensureAuthProfileStore, + type AuthProfileStore, + type AuthProfileCredential, +} from "../../agents/auth-profiles.js"; +import { markAuthProfileGood, setAuthProfileOrder } from "../../agents/auth-profiles/profiles.js"; +import { ErrorCodes, errorShape } from "../protocol/index.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +export type ProviderProfile = { + id: string; + provider: string; + type: "api_key" | "oauth" | "token"; + label: string; + isActive: boolean; + expiresAt?: number; + email?: string; +}; + +export type ProvidersListResponse = { + profiles: Record; + lastGood?: Record; + order?: Record; +}; + +export type ProviderActiveResponse = { + activeProfile: ProviderProfile | null; + provider: string | null; +}; + +function resolveProfileLabel(id: string, cred: AuthProfileCredential): string { + const parts = id.split(":"); + const suffix = parts.length > 1 ? parts.slice(1).join(":") : "default"; + const typeLabel = cred.type === "oauth" ? "OAuth" : cred.type === "api_key" ? "API Key" : "Token"; + return `${cred.provider} (${typeLabel}) - ${suffix}`; +} + +function mapStoreToProfiles( + store: AuthProfileStore, + activeProfileId: string | null, +): Record { + const result: Record = {}; + for (const [id, cred] of Object.entries(store.profiles)) { + result[id] = { + id, + provider: cred.provider, + type: cred.type, + label: resolveProfileLabel(id, cred), + isActive: id === activeProfileId, + expiresAt: "expires" in cred ? cred.expires : undefined, + email: "email" in cred ? cred.email : undefined, + }; + } + return result; +} + +function resolveActiveProfileId(store: AuthProfileStore, provider = "anthropic"): string | null { + if (store.lastGood?.[provider]) { + return store.lastGood[provider]; + } + if (store.order?.[provider]?.[0]) { + return store.order[provider][0]; + } + const profilesForProvider = Object.entries(store.profiles).filter( + ([, cred]) => cred.provider === provider, + ); + if (profilesForProvider.length > 0) { + return profilesForProvider[0][0]; + } + return null; +} + +export const providersHandlers: GatewayRequestHandlers = { + "providers.list": async ({ respond }) => { + try { + const store = ensureAuthProfileStore(); + const activeProfileId = resolveActiveProfileId(store); + + const response: ProvidersListResponse = { + profiles: mapStoreToProfiles(store, activeProfileId), + lastGood: store.lastGood, + order: store.order, + }; + + respond(true, response, undefined); + } catch (err) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err))); + } + }, + + "providers.active": async ({ params, respond }) => { + try { + const provider = typeof params.provider === "string" ? params.provider : "anthropic"; + const store = ensureAuthProfileStore(); + const activeProfileId = resolveActiveProfileId(store, provider); + + let activeProfile: ProviderProfile | null = null; + if (activeProfileId && store.profiles[activeProfileId]) { + const cred = store.profiles[activeProfileId]; + activeProfile = { + id: activeProfileId, + provider: cred.provider, + type: cred.type, + label: resolveProfileLabel(activeProfileId, cred), + isActive: true, + expiresAt: "expires" in cred ? cred.expires : undefined, + email: "email" in cred ? cred.email : undefined, + }; + } + + const response: ProviderActiveResponse = { + activeProfile, + provider, + }; + + respond(true, response, undefined); + } catch (err) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err))); + } + }, + + "providers.switch": async ({ params, respond }) => { + const profileId = params.profileId; + if (typeof profileId !== "string" || !profileId.trim()) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "profileId parameter is required"), + ); + return; + } + + try { + const store = ensureAuthProfileStore(); + + if (!store.profiles[profileId]) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `Profile not found: ${profileId}`), + ); + return; + } + + const cred = store.profiles[profileId]; + const provider = cred.provider; + + // Mark as last good for this provider + await markAuthProfileGood({ store, provider, profileId }); + + // Update order to prioritize this profile + const currentOrder = store.order?.[provider] ?? []; + const newOrder = [profileId, ...currentOrder.filter((id) => id !== profileId)]; + await setAuthProfileOrder({ provider, order: newOrder }); + + const response = { + success: true, + activeProfile: { + id: profileId, + provider: cred.provider, + type: cred.type, + label: resolveProfileLabel(profileId, cred), + isActive: true, + expiresAt: "expires" in cred ? cred.expires : undefined, + email: "email" in cred ? cred.email : undefined, + }, + }; + + respond(true, response, undefined); + } catch (err) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err))); + } + }, + + "providers.order": async ({ params, respond }) => { + const provider = params.provider; + const order = params.order; + + if (typeof provider !== "string" || !provider.trim()) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "provider parameter is required"), + ); + return; + } + + if (!Array.isArray(order) || !order.every((id) => typeof id === "string")) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "order parameter must be an array of profile IDs"), + ); + return; + } + + try { + await setAuthProfileOrder({ provider, order }); + + respond(true, { success: true, provider, order }, undefined); + } catch (err) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err))); + } + }, +}; diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index b2355709c..dba2bbda9 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -2,6 +2,7 @@ import { loadChatHistory } from "./controllers/chat"; import { loadDevices } from "./controllers/devices"; import { loadNodes } from "./controllers/nodes"; import { loadAgents } from "./controllers/agents"; +import { loadProviders } from "./controllers/providers"; import type { GatewayEventFrame, GatewayHelloOk } from "./gateway"; import { GatewayBrowserClient } from "./gateway"; import type { EventLogEntry } from "./app-events"; @@ -10,12 +11,7 @@ import type { Tab } from "./navigation"; import type { UiSettings } from "./storage"; import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream"; import { flushChatQueueForEvent } from "./app-chat"; -import { - applySettings, - loadCron, - refreshActiveTab, - setLastActiveSessionKey, -} from "./app-settings"; +import { applySettings, loadCron, refreshActiveTab, setLastActiveSessionKey } from "./app-settings"; import { handleChatEvent, type ChatEventPayload } from "./controllers/chat"; import { addExecApproval, @@ -75,8 +71,7 @@ function normalizeSessionKeyForDefaults( raw === "main" || raw === mainKey || (defaultAgentId && - (raw === `agent:${defaultAgentId}:main` || - raw === `agent:${defaultAgentId}:${mainKey}`)); + (raw === `agent:${defaultAgentId}:main` || raw === `agent:${defaultAgentId}:${mainKey}`)); return isAlias ? mainSessionKey : raw; } @@ -135,6 +130,7 @@ export function connectGateway(host: GatewayHost) { resetToolStream(host as unknown as Parameters[0]); void loadAssistantIdentity(host as unknown as MoltbotApp); void loadAgents(host as unknown as MoltbotApp); + void loadProviders(host as unknown as MoltbotApp); void loadNodes(host as unknown as MoltbotApp, { quiet: true }); void loadDevices(host as unknown as MoltbotApp, { quiet: true }); void refreshActiveTab(host as unknown as Parameters[0]); @@ -191,9 +187,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { const state = handleChatEvent(host as unknown as MoltbotApp, payload); if (state === "final" || state === "error" || state === "aborted") { resetToolStream(host as unknown as Parameters[0]); - void flushChatQueueForEvent( - host as unknown as Parameters[0], - ); + void flushChatQueueForEvent(host as unknown as Parameters[0]); } if (state === "final") void loadChatHistory(host as unknown as MoltbotApp); return; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a088c33ff..c258474f3 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -78,9 +78,17 @@ import { saveExecApprovals, updateExecApprovalsFormValue, } from "./controllers/exec-approvals"; -import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron"; +import { + loadCronRuns, + toggleCronJob, + runCronJob, + removeCronJob, + addCronJob, +} from "./controllers/cron"; import { loadDebug, callDebugMethod } from "./controllers/debug"; import { loadLogs } from "./controllers/logs"; +import { renderProviderSwitcher } from "./views/providers"; +import { loadProviders, switchProvider } from "./controllers/providers"; const AVATAR_DATA_RE = /^data:/i; const AVATAR_HTTP_RE = /^https?:\/\//i; @@ -88,10 +96,7 @@ const AVATAR_HTTP_RE = /^https?:\/\//i; function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { const list = state.agentsList?.agents ?? []; const parsed = parseAgentSessionKey(state.sessionKey); - const agentId = - parsed?.agentId ?? - state.agentsList?.defaultId ?? - "main"; + const agentId = parsed?.agentId ?? state.agentsList?.defaultId ?? "main"; const agent = list.find((entry) => entry.id === agentId); const identity = agent?.identity; const candidate = identity?.avatarUrl ?? identity?.avatar; @@ -143,6 +148,16 @@ export function renderApp(state: AppViewState) { Health ${state.connected ? "OK" : "Offline"} + ${renderProviderSwitcher({ + connected: state.connected, + loading: state.providersLoading, + switching: state.providerSwitching, + error: state.providersError, + providersList: state.providersList, + activeProvider: state.activeProvider, + onSwitch: (profileId) => switchProvider(state, profileId), + onRefresh: () => loadProviders(state), + })} ${renderThemeToggle(state)} @@ -198,384 +213,403 @@ export function renderApp(state: AppViewState) {
${subtitleForTab(state.tab)}
- ${state.lastError - ? html`
${state.lastError}
` - : nothing} + ${state.lastError ? html`
${state.lastError}
` : nothing} ${isChat ? renderChatControls(state) : nothing}
- ${state.tab === "overview" - ? renderOverview({ - connected: state.connected, - hello: state.hello, - settings: state.settings, - password: state.password, - lastError: state.lastError, - presenceCount, - sessionsCount, - cronEnabled: state.cronStatus?.enabled ?? null, - cronNext, - lastChannelsRefresh: state.channelsLastSuccess, - onSettingsChange: (next) => state.applySettings(next), - onPasswordChange: (next) => (state.password = next), - onSessionKeyChange: (next) => { - state.sessionKey = next; - state.chatMessage = ""; - state.resetToolStream(); - state.applySettings({ - ...state.settings, - sessionKey: next, - lastActiveSessionKey: next, - }); - void state.loadAssistantIdentity(); - }, - onConnect: () => state.connect(), - onRefresh: () => state.loadOverview(), - }) - : nothing} + ${ + state.tab === "overview" + ? renderOverview({ + connected: state.connected, + hello: state.hello, + settings: state.settings, + password: state.password, + lastError: state.lastError, + presenceCount, + sessionsCount, + cronEnabled: state.cronStatus?.enabled ?? null, + cronNext, + lastChannelsRefresh: state.channelsLastSuccess, + onSettingsChange: (next) => state.applySettings(next), + onPasswordChange: (next) => (state.password = next), + onSessionKeyChange: (next) => { + state.sessionKey = next; + state.chatMessage = ""; + state.resetToolStream(); + state.applySettings({ + ...state.settings, + sessionKey: next, + lastActiveSessionKey: next, + }); + void state.loadAssistantIdentity(); + }, + onConnect: () => state.connect(), + onRefresh: () => state.loadOverview(), + }) + : nothing + } - ${state.tab === "channels" - ? renderChannels({ - connected: state.connected, - loading: state.channelsLoading, - snapshot: state.channelsSnapshot, - lastError: state.channelsError, - lastSuccessAt: state.channelsLastSuccess, - whatsappMessage: state.whatsappLoginMessage, - whatsappQrDataUrl: state.whatsappLoginQrDataUrl, - whatsappConnected: state.whatsappLoginConnected, - whatsappBusy: state.whatsappBusy, - configSchema: state.configSchema, - configSchemaLoading: state.configSchemaLoading, - configForm: state.configForm, - configUiHints: state.configUiHints, - configSaving: state.configSaving, - configFormDirty: state.configFormDirty, - nostrProfileFormState: state.nostrProfileFormState, - nostrProfileAccountId: state.nostrProfileAccountId, - onRefresh: (probe) => loadChannels(state, probe), - onWhatsAppStart: (force) => state.handleWhatsAppStart(force), - onWhatsAppWait: () => state.handleWhatsAppWait(), - onWhatsAppLogout: () => state.handleWhatsAppLogout(), - onConfigPatch: (path, value) => updateConfigFormValue(state, path, value), - onConfigSave: () => state.handleChannelConfigSave(), - onConfigReload: () => state.handleChannelConfigReload(), - onNostrProfileEdit: (accountId, profile) => - state.handleNostrProfileEdit(accountId, profile), - onNostrProfileCancel: () => state.handleNostrProfileCancel(), - onNostrProfileFieldChange: (field, value) => - state.handleNostrProfileFieldChange(field, value), - onNostrProfileSave: () => state.handleNostrProfileSave(), - onNostrProfileImport: () => state.handleNostrProfileImport(), - onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(), - }) - : nothing} + ${ + state.tab === "channels" + ? renderChannels({ + connected: state.connected, + loading: state.channelsLoading, + snapshot: state.channelsSnapshot, + lastError: state.channelsError, + lastSuccessAt: state.channelsLastSuccess, + whatsappMessage: state.whatsappLoginMessage, + whatsappQrDataUrl: state.whatsappLoginQrDataUrl, + whatsappConnected: state.whatsappLoginConnected, + whatsappBusy: state.whatsappBusy, + configSchema: state.configSchema, + configSchemaLoading: state.configSchemaLoading, + configForm: state.configForm, + configUiHints: state.configUiHints, + configSaving: state.configSaving, + configFormDirty: state.configFormDirty, + nostrProfileFormState: state.nostrProfileFormState, + nostrProfileAccountId: state.nostrProfileAccountId, + onRefresh: (probe) => loadChannels(state, probe), + onWhatsAppStart: (force) => state.handleWhatsAppStart(force), + onWhatsAppWait: () => state.handleWhatsAppWait(), + onWhatsAppLogout: () => state.handleWhatsAppLogout(), + onConfigPatch: (path, value) => updateConfigFormValue(state, path, value), + onConfigSave: () => state.handleChannelConfigSave(), + onConfigReload: () => state.handleChannelConfigReload(), + onNostrProfileEdit: (accountId, profile) => + state.handleNostrProfileEdit(accountId, profile), + onNostrProfileCancel: () => state.handleNostrProfileCancel(), + onNostrProfileFieldChange: (field, value) => + state.handleNostrProfileFieldChange(field, value), + onNostrProfileSave: () => state.handleNostrProfileSave(), + onNostrProfileImport: () => state.handleNostrProfileImport(), + onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(), + }) + : nothing + } - ${state.tab === "instances" - ? renderInstances({ - loading: state.presenceLoading, - entries: state.presenceEntries, - lastError: state.presenceError, - statusMessage: state.presenceStatus, - onRefresh: () => loadPresence(state), - }) - : nothing} + ${ + state.tab === "instances" + ? renderInstances({ + loading: state.presenceLoading, + entries: state.presenceEntries, + lastError: state.presenceError, + statusMessage: state.presenceStatus, + onRefresh: () => loadPresence(state), + }) + : nothing + } - ${state.tab === "sessions" - ? renderSessions({ - loading: state.sessionsLoading, - result: state.sessionsResult, - error: state.sessionsError, - activeMinutes: state.sessionsFilterActive, - limit: state.sessionsFilterLimit, - includeGlobal: state.sessionsIncludeGlobal, - includeUnknown: state.sessionsIncludeUnknown, - basePath: state.basePath, - onFiltersChange: (next) => { - state.sessionsFilterActive = next.activeMinutes; - state.sessionsFilterLimit = next.limit; - state.sessionsIncludeGlobal = next.includeGlobal; - state.sessionsIncludeUnknown = next.includeUnknown; - }, - onRefresh: () => loadSessions(state), - onPatch: (key, patch) => patchSession(state, key, patch), - onDelete: (key) => deleteSession(state, key), - }) - : nothing} + ${ + state.tab === "sessions" + ? renderSessions({ + loading: state.sessionsLoading, + result: state.sessionsResult, + error: state.sessionsError, + activeMinutes: state.sessionsFilterActive, + limit: state.sessionsFilterLimit, + includeGlobal: state.sessionsIncludeGlobal, + includeUnknown: state.sessionsIncludeUnknown, + basePath: state.basePath, + onFiltersChange: (next) => { + state.sessionsFilterActive = next.activeMinutes; + state.sessionsFilterLimit = next.limit; + state.sessionsIncludeGlobal = next.includeGlobal; + state.sessionsIncludeUnknown = next.includeUnknown; + }, + onRefresh: () => loadSessions(state), + onPatch: (key, patch) => patchSession(state, key, patch), + onDelete: (key) => deleteSession(state, key), + }) + : nothing + } - ${state.tab === "cron" - ? renderCron({ - loading: state.cronLoading, - status: state.cronStatus, - jobs: state.cronJobs, - error: state.cronError, - busy: state.cronBusy, - form: state.cronForm, - channels: state.channelsSnapshot?.channelMeta?.length - ? state.channelsSnapshot.channelMeta.map((entry) => entry.id) - : state.channelsSnapshot?.channelOrder ?? [], - channelLabels: state.channelsSnapshot?.channelLabels ?? {}, - channelMeta: state.channelsSnapshot?.channelMeta ?? [], - runsJobId: state.cronRunsJobId, - runs: state.cronRuns, - onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }), - onRefresh: () => state.loadCron(), - onAdd: () => addCronJob(state), - onToggle: (job, enabled) => toggleCronJob(state, job, enabled), - onRun: (job) => runCronJob(state, job), - onRemove: (job) => removeCronJob(state, job), - onLoadRuns: (jobId) => loadCronRuns(state, jobId), - }) - : nothing} + ${ + state.tab === "cron" + ? renderCron({ + loading: state.cronLoading, + status: state.cronStatus, + jobs: state.cronJobs, + error: state.cronError, + busy: state.cronBusy, + form: state.cronForm, + channels: state.channelsSnapshot?.channelMeta?.length + ? state.channelsSnapshot.channelMeta.map((entry) => entry.id) + : (state.channelsSnapshot?.channelOrder ?? []), + channelLabels: state.channelsSnapshot?.channelLabels ?? {}, + channelMeta: state.channelsSnapshot?.channelMeta ?? [], + runsJobId: state.cronRunsJobId, + runs: state.cronRuns, + onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }), + onRefresh: () => state.loadCron(), + onAdd: () => addCronJob(state), + onToggle: (job, enabled) => toggleCronJob(state, job, enabled), + onRun: (job) => runCronJob(state, job), + onRemove: (job) => removeCronJob(state, job), + onLoadRuns: (jobId) => loadCronRuns(state, jobId), + }) + : nothing + } - ${state.tab === "skills" - ? renderSkills({ - loading: state.skillsLoading, - report: state.skillsReport, - error: state.skillsError, - filter: state.skillsFilter, - edits: state.skillEdits, - messages: state.skillMessages, - busyKey: state.skillsBusyKey, - onFilterChange: (next) => (state.skillsFilter = next), - onRefresh: () => loadSkills(state, { clearMessages: true }), - onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled), - onEdit: (key, value) => updateSkillEdit(state, key, value), - onSaveKey: (key) => saveSkillApiKey(state, key), - onInstall: (skillKey, name, installId) => - installSkill(state, skillKey, name, installId), - }) - : nothing} + ${ + state.tab === "skills" + ? renderSkills({ + loading: state.skillsLoading, + report: state.skillsReport, + error: state.skillsError, + filter: state.skillsFilter, + edits: state.skillEdits, + messages: state.skillMessages, + busyKey: state.skillsBusyKey, + onFilterChange: (next) => (state.skillsFilter = next), + onRefresh: () => loadSkills(state, { clearMessages: true }), + onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled), + onEdit: (key, value) => updateSkillEdit(state, key, value), + onSaveKey: (key) => saveSkillApiKey(state, key), + onInstall: (skillKey, name, installId) => + installSkill(state, skillKey, name, installId), + }) + : nothing + } - ${state.tab === "nodes" - ? renderNodes({ - loading: state.nodesLoading, - nodes: state.nodes, - devicesLoading: state.devicesLoading, - devicesError: state.devicesError, - devicesList: state.devicesList, - configForm: state.configForm ?? (state.configSnapshot?.config as Record | null), - configLoading: state.configLoading, - configSaving: state.configSaving, - configDirty: state.configFormDirty, - configFormMode: state.configFormMode, - execApprovalsLoading: state.execApprovalsLoading, - execApprovalsSaving: state.execApprovalsSaving, - execApprovalsDirty: state.execApprovalsDirty, - execApprovalsSnapshot: state.execApprovalsSnapshot, - execApprovalsForm: state.execApprovalsForm, - execApprovalsSelectedAgent: state.execApprovalsSelectedAgent, - execApprovalsTarget: state.execApprovalsTarget, - execApprovalsTargetNodeId: state.execApprovalsTargetNodeId, - onRefresh: () => loadNodes(state), - onDevicesRefresh: () => loadDevices(state), - onDeviceApprove: (requestId) => approveDevicePairing(state, requestId), - onDeviceReject: (requestId) => rejectDevicePairing(state, requestId), - onDeviceRotate: (deviceId, role, scopes) => - rotateDeviceToken(state, { deviceId, role, scopes }), - onDeviceRevoke: (deviceId, role) => - revokeDeviceToken(state, { deviceId, role }), - onLoadConfig: () => loadConfig(state), - onLoadExecApprovals: () => { - const target = - state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId - ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } - : { kind: "gateway" as const }; - return loadExecApprovals(state, target); - }, - onBindDefault: (nodeId) => { - if (nodeId) { - updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); - } else { - removeConfigFormValue(state, ["tools", "exec", "node"]); - } - }, - onBindAgent: (agentIndex, nodeId) => { - const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"]; - if (nodeId) { - updateConfigFormValue(state, basePath, nodeId); - } else { - removeConfigFormValue(state, basePath); - } - }, - onSaveBindings: () => saveConfig(state), - onExecApprovalsTargetChange: (kind, nodeId) => { - state.execApprovalsTarget = kind; - state.execApprovalsTargetNodeId = nodeId; - state.execApprovalsSnapshot = null; - state.execApprovalsForm = null; - state.execApprovalsDirty = false; - state.execApprovalsSelectedAgent = null; - }, - onExecApprovalsSelectAgent: (agentId) => { - state.execApprovalsSelectedAgent = agentId; - }, - onExecApprovalsPatch: (path, value) => - updateExecApprovalsFormValue(state, path, value), - onExecApprovalsRemove: (path) => - removeExecApprovalsFormValue(state, path), - onSaveExecApprovals: () => { - const target = - state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId - ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } - : { kind: "gateway" as const }; - return saveExecApprovals(state, target); - }, - }) - : nothing} + ${ + state.tab === "nodes" + ? renderNodes({ + loading: state.nodesLoading, + nodes: state.nodes, + devicesLoading: state.devicesLoading, + devicesError: state.devicesError, + devicesList: state.devicesList, + configForm: + state.configForm ?? + (state.configSnapshot?.config as Record | null), + configLoading: state.configLoading, + configSaving: state.configSaving, + configDirty: state.configFormDirty, + configFormMode: state.configFormMode, + execApprovalsLoading: state.execApprovalsLoading, + execApprovalsSaving: state.execApprovalsSaving, + execApprovalsDirty: state.execApprovalsDirty, + execApprovalsSnapshot: state.execApprovalsSnapshot, + execApprovalsForm: state.execApprovalsForm, + execApprovalsSelectedAgent: state.execApprovalsSelectedAgent, + execApprovalsTarget: state.execApprovalsTarget, + execApprovalsTargetNodeId: state.execApprovalsTargetNodeId, + onRefresh: () => loadNodes(state), + onDevicesRefresh: () => loadDevices(state), + onDeviceApprove: (requestId) => approveDevicePairing(state, requestId), + onDeviceReject: (requestId) => rejectDevicePairing(state, requestId), + onDeviceRotate: (deviceId, role, scopes) => + rotateDeviceToken(state, { deviceId, role, scopes }), + onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }), + onLoadConfig: () => loadConfig(state), + onLoadExecApprovals: () => { + const target = + state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId + ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } + : { kind: "gateway" as const }; + return loadExecApprovals(state, target); + }, + onBindDefault: (nodeId) => { + if (nodeId) { + updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); + } else { + removeConfigFormValue(state, ["tools", "exec", "node"]); + } + }, + onBindAgent: (agentIndex, nodeId) => { + const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"]; + if (nodeId) { + updateConfigFormValue(state, basePath, nodeId); + } else { + removeConfigFormValue(state, basePath); + } + }, + onSaveBindings: () => saveConfig(state), + onExecApprovalsTargetChange: (kind, nodeId) => { + state.execApprovalsTarget = kind; + state.execApprovalsTargetNodeId = nodeId; + state.execApprovalsSnapshot = null; + state.execApprovalsForm = null; + state.execApprovalsDirty = false; + state.execApprovalsSelectedAgent = null; + }, + onExecApprovalsSelectAgent: (agentId) => { + state.execApprovalsSelectedAgent = agentId; + }, + onExecApprovalsPatch: (path, value) => + updateExecApprovalsFormValue(state, path, value), + onExecApprovalsRemove: (path) => removeExecApprovalsFormValue(state, path), + onSaveExecApprovals: () => { + const target = + state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId + ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } + : { kind: "gateway" as const }; + return saveExecApprovals(state, target); + }, + }) + : nothing + } - ${state.tab === "chat" - ? renderChat({ - sessionKey: state.sessionKey, - onSessionKeyChange: (next) => { - state.sessionKey = next; - state.chatMessage = ""; - state.chatAttachments = []; - state.chatStream = null; - state.chatStreamStartedAt = null; - state.chatRunId = null; - state.chatQueue = []; - state.resetToolStream(); - state.resetChatScroll(); - state.applySettings({ - ...state.settings, - sessionKey: next, - lastActiveSessionKey: next, - }); - void state.loadAssistantIdentity(); - void loadChatHistory(state); - void refreshChatAvatar(state); - }, - thinkingLevel: state.chatThinkingLevel, - showThinking, - loading: state.chatLoading, - sending: state.chatSending, - compactionStatus: state.compactionStatus, - assistantAvatarUrl: chatAvatarUrl, - messages: state.chatMessages, - toolMessages: state.chatToolMessages, - stream: state.chatStream, - streamStartedAt: state.chatStreamStartedAt, - draft: state.chatMessage, - queue: state.chatQueue, - connected: state.connected, - canSend: state.connected, - disabledReason: chatDisabledReason, - error: state.lastError, - sessions: state.sessionsResult, - focusMode: chatFocus, - onRefresh: () => { - state.resetToolStream(); - return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); - }, - onToggleFocusMode: () => { - if (state.onboarding) return; - state.applySettings({ - ...state.settings, - chatFocusMode: !state.settings.chatFocusMode, - }); - }, - onChatScroll: (event) => state.handleChatScroll(event), - onDraftChange: (next) => (state.chatMessage = next), - attachments: state.chatAttachments, - onAttachmentsChange: (next) => (state.chatAttachments = next), - onSend: () => state.handleSendChat(), - canAbort: Boolean(state.chatRunId), - onAbort: () => void state.handleAbortChat(), - onQueueRemove: (id) => state.removeQueuedMessage(id), - onNewSession: () => - state.handleSendChat("/new", { restoreDraft: true }), - // Sidebar props for tool output viewing - sidebarOpen: state.sidebarOpen, - sidebarContent: state.sidebarContent, - sidebarError: state.sidebarError, - splitRatio: state.splitRatio, - onOpenSidebar: (content: string) => state.handleOpenSidebar(content), - onCloseSidebar: () => state.handleCloseSidebar(), - onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), - assistantName: state.assistantName, - assistantAvatar: state.assistantAvatar, - }) - : nothing} + ${ + state.tab === "chat" + ? renderChat({ + sessionKey: state.sessionKey, + onSessionKeyChange: (next) => { + state.sessionKey = next; + state.chatMessage = ""; + state.chatAttachments = []; + state.chatStream = null; + state.chatStreamStartedAt = null; + state.chatRunId = null; + state.chatQueue = []; + state.resetToolStream(); + state.resetChatScroll(); + state.applySettings({ + ...state.settings, + sessionKey: next, + lastActiveSessionKey: next, + }); + void state.loadAssistantIdentity(); + void loadChatHistory(state); + void refreshChatAvatar(state); + }, + thinkingLevel: state.chatThinkingLevel, + showThinking, + loading: state.chatLoading, + sending: state.chatSending, + compactionStatus: state.compactionStatus, + assistantAvatarUrl: chatAvatarUrl, + messages: state.chatMessages, + toolMessages: state.chatToolMessages, + stream: state.chatStream, + streamStartedAt: state.chatStreamStartedAt, + draft: state.chatMessage, + queue: state.chatQueue, + connected: state.connected, + canSend: state.connected, + disabledReason: chatDisabledReason, + error: state.lastError, + sessions: state.sessionsResult, + focusMode: chatFocus, + onRefresh: () => { + state.resetToolStream(); + return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); + }, + onToggleFocusMode: () => { + if (state.onboarding) return; + state.applySettings({ + ...state.settings, + chatFocusMode: !state.settings.chatFocusMode, + }); + }, + onChatScroll: (event) => state.handleChatScroll(event), + onDraftChange: (next) => (state.chatMessage = next), + attachments: state.chatAttachments, + onAttachmentsChange: (next) => (state.chatAttachments = next), + onSend: () => state.handleSendChat(), + canAbort: Boolean(state.chatRunId), + onAbort: () => void state.handleAbortChat(), + onQueueRemove: (id) => state.removeQueuedMessage(id), + onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }), + // Sidebar props for tool output viewing + sidebarOpen: state.sidebarOpen, + sidebarContent: state.sidebarContent, + sidebarError: state.sidebarError, + splitRatio: state.splitRatio, + onOpenSidebar: (content: string) => state.handleOpenSidebar(content), + onCloseSidebar: () => state.handleCloseSidebar(), + onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), + assistantName: state.assistantName, + assistantAvatar: state.assistantAvatar, + }) + : nothing + } - ${state.tab === "config" - ? renderConfig({ - raw: state.configRaw, - originalRaw: state.configRawOriginal, - valid: state.configValid, - issues: state.configIssues, - loading: state.configLoading, - saving: state.configSaving, - applying: state.configApplying, - updating: state.updateRunning, - connected: state.connected, - schema: state.configSchema, - schemaLoading: state.configSchemaLoading, - uiHints: state.configUiHints, - formMode: state.configFormMode, - formValue: state.configForm, - originalValue: state.configFormOriginal, - searchQuery: state.configSearchQuery, - activeSection: state.configActiveSection, - activeSubsection: state.configActiveSubsection, - onRawChange: (next) => { - state.configRaw = next; - }, - onFormModeChange: (mode) => (state.configFormMode = mode), - onFormPatch: (path, value) => updateConfigFormValue(state, path, value), - onSearchChange: (query) => (state.configSearchQuery = query), - onSectionChange: (section) => { - state.configActiveSection = section; - state.configActiveSubsection = null; - }, - onSubsectionChange: (section) => (state.configActiveSubsection = section), - onReload: () => loadConfig(state), - onSave: () => saveConfig(state), - onApply: () => applyConfig(state), - onUpdate: () => runUpdate(state), - }) - : nothing} + ${ + state.tab === "config" + ? renderConfig({ + raw: state.configRaw, + originalRaw: state.configRawOriginal, + valid: state.configValid, + issues: state.configIssues, + loading: state.configLoading, + saving: state.configSaving, + applying: state.configApplying, + updating: state.updateRunning, + connected: state.connected, + schema: state.configSchema, + schemaLoading: state.configSchemaLoading, + uiHints: state.configUiHints, + formMode: state.configFormMode, + formValue: state.configForm, + originalValue: state.configFormOriginal, + searchQuery: state.configSearchQuery, + activeSection: state.configActiveSection, + activeSubsection: state.configActiveSubsection, + onRawChange: (next) => { + state.configRaw = next; + }, + onFormModeChange: (mode) => (state.configFormMode = mode), + onFormPatch: (path, value) => updateConfigFormValue(state, path, value), + onSearchChange: (query) => (state.configSearchQuery = query), + onSectionChange: (section) => { + state.configActiveSection = section; + state.configActiveSubsection = null; + }, + onSubsectionChange: (section) => (state.configActiveSubsection = section), + onReload: () => loadConfig(state), + onSave: () => saveConfig(state), + onApply: () => applyConfig(state), + onUpdate: () => runUpdate(state), + }) + : nothing + } - ${state.tab === "debug" - ? renderDebug({ - loading: state.debugLoading, - status: state.debugStatus, - health: state.debugHealth, - models: state.debugModels, - heartbeat: state.debugHeartbeat, - eventLog: state.eventLog, - callMethod: state.debugCallMethod, - callParams: state.debugCallParams, - callResult: state.debugCallResult, - callError: state.debugCallError, - onCallMethodChange: (next) => (state.debugCallMethod = next), - onCallParamsChange: (next) => (state.debugCallParams = next), - onRefresh: () => loadDebug(state), - onCall: () => callDebugMethod(state), - }) - : nothing} + ${ + state.tab === "debug" + ? renderDebug({ + loading: state.debugLoading, + status: state.debugStatus, + health: state.debugHealth, + models: state.debugModels, + heartbeat: state.debugHeartbeat, + eventLog: state.eventLog, + callMethod: state.debugCallMethod, + callParams: state.debugCallParams, + callResult: state.debugCallResult, + callError: state.debugCallError, + onCallMethodChange: (next) => (state.debugCallMethod = next), + onCallParamsChange: (next) => (state.debugCallParams = next), + onRefresh: () => loadDebug(state), + onCall: () => callDebugMethod(state), + }) + : nothing + } - ${state.tab === "logs" - ? renderLogs({ - loading: state.logsLoading, - error: state.logsError, - file: state.logsFile, - entries: state.logsEntries, - filterText: state.logsFilterText, - levelFilters: state.logsLevelFilters, - autoFollow: state.logsAutoFollow, - truncated: state.logsTruncated, - onFilterTextChange: (next) => (state.logsFilterText = next), - onLevelToggle: (level, enabled) => { - state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled }; - }, - onToggleAutoFollow: (next) => (state.logsAutoFollow = next), - onRefresh: () => loadLogs(state, { reset: true }), - onExport: (lines, label) => state.exportLogs(lines, label), - onScroll: (event) => state.handleLogsScroll(event), - }) - : nothing} + ${ + state.tab === "logs" + ? renderLogs({ + loading: state.logsLoading, + error: state.logsError, + file: state.logsFile, + entries: state.logsEntries, + filterText: state.logsFilterText, + levelFilters: state.logsLevelFilters, + autoFollow: state.logsAutoFollow, + truncated: state.logsTruncated, + onFilterTextChange: (next) => (state.logsFilterText = next), + onLevelToggle: (level, enabled) => { + state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled }; + }, + onToggleAutoFollow: (next) => (state.logsAutoFollow = next), + onRefresh: () => loadLogs(state, { reset: true }), + onExport: (lines, label) => state.exportLogs(lines, label), + onScroll: (event) => state.handleLogsScroll(event), + }) + : nothing + } ${renderExecApprovalPrompt(state)} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 069465e32..d6304bc2b 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -22,10 +22,7 @@ import type { import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types"; import type { EventLogEntry } from "./app-events"; import type { SkillMessage } from "./controllers/skills"; -import type { - ExecApprovalsFile, - ExecApprovalsSnapshot, -} from "./controllers/exec-approvals"; +import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals"; import type { DevicePairingList } from "./controllers/devices"; import type { ExecApprovalRequest } from "./controllers/exec-approval"; import type { NostrProfileFormState } from "./views/channels.nostr-profile-form"; @@ -106,6 +103,11 @@ export type AppViewState = { agentsLoading: boolean; agentsList: AgentsListResult | null; agentsError: string | null; + providersLoading: boolean; + providersList: import("./controllers/providers").ProvidersListResult | null; + providersError: string | null; + activeProvider: import("./controllers/providers").ProviderProfile | null; + providerSwitching: boolean; sessionsLoading: boolean; sessionsResult: SessionsListResult | null; sessionsError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index d23e543cd..5fd600661 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -27,10 +27,7 @@ import type { import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types"; import type { EventLogEntry } from "./app-events"; import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults"; -import type { - ExecApprovalsFile, - ExecApprovalsSnapshot, -} from "./controllers/exec-approvals"; +import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals"; import type { DevicePairingList } from "./controllers/devices"; import type { ExecApprovalRequest } from "./controllers/exec-approval"; import { @@ -195,6 +192,12 @@ export class MoltbotApp extends LitElement { @state() agentsList: AgentsListResult | null = null; @state() agentsError: string | null = null; + @state() providersLoading = false; + @state() providersList: import("./controllers/providers").ProvidersListResult | null = null; + @state() providersError: string | null = null; + @state() activeProvider: import("./controllers/providers").ProviderProfile | null = null; + @state() providerSwitching = false; + @state() sessionsLoading = false; @state() sessionsResult: SessionsListResult | null = null; @state() sessionsError: string | null = null; @@ -259,9 +262,7 @@ export class MoltbotApp extends LitElement { private toolStreamOrder: string[] = []; basePath = ""; private popStateHandler = () => - onPopStateInternal( - this as unknown as Parameters[0], - ); + onPopStateInternal(this as unknown as Parameters[0]); private themeMedia: MediaQueryList | null = null; private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null; private topbarObserver: ResizeObserver | null = null; @@ -285,16 +286,11 @@ export class MoltbotApp extends LitElement { } protected updated(changed: Map) { - handleUpdated( - this as unknown as Parameters[0], - changed, - ); + handleUpdated(this as unknown as Parameters[0], changed); } connect() { - connectGatewayInternal( - this as unknown as Parameters[0], - ); + connectGatewayInternal(this as unknown as Parameters[0]); } handleChatScroll(event: Event) { @@ -316,15 +312,11 @@ export class MoltbotApp extends LitElement { } resetToolStream() { - resetToolStreamInternal( - this as unknown as Parameters[0], - ); + resetToolStreamInternal(this as unknown as Parameters[0]); } resetChatScroll() { - resetChatScrollInternal( - this as unknown as Parameters[0], - ); + resetChatScrollInternal(this as unknown as Parameters[0]); } async loadAssistantIdentity() { @@ -332,10 +324,7 @@ export class MoltbotApp extends LitElement { } applySettings(next: UiSettings) { - applySettingsInternal( - this as unknown as Parameters[0], - next, - ); + applySettingsInternal(this as unknown as Parameters[0], next); } setTab(next: Tab) { @@ -343,29 +332,29 @@ export class MoltbotApp extends LitElement { } setTheme(next: ThemeMode, context?: Parameters[2]) { - setThemeInternal( - this as unknown as Parameters[0], - next, - context, - ); + setThemeInternal(this as unknown as Parameters[0], next, context); } async loadOverview() { - await loadOverviewInternal( - this as unknown as Parameters[0], - ); + await loadOverviewInternal(this as unknown as Parameters[0]); } async loadCron() { - await loadCronInternal( - this as unknown as Parameters[0], - ); + await loadCronInternal(this as unknown as Parameters[0]); + } + + async loadProviders() { + const { loadProviders } = await import("./controllers/providers.js"); + await loadProviders(this as unknown as Parameters[0]); + } + + async switchProvider(profileId: string) { + const { switchProvider } = await import("./controllers/providers.js"); + await switchProvider(this as unknown as Parameters[0], profileId); } async handleAbortChat() { - await handleAbortChatInternal( - this as unknown as Parameters[0], - ); + await handleAbortChatInternal(this as unknown as Parameters[0]); } removeQueuedMessage(id: string) { diff --git a/ui/src/ui/controllers/providers.ts b/ui/src/ui/controllers/providers.ts new file mode 100644 index 000000000..3bd300ab5 --- /dev/null +++ b/ui/src/ui/controllers/providers.ts @@ -0,0 +1,106 @@ +import type { GatewayBrowserClient } from "../gateway"; + +export type ProviderProfile = { + id: string; + provider: string; + type: "api_key" | "oauth" | "token"; + label: string; + isActive: boolean; + expiresAt?: number; + email?: string; +}; + +export type ProvidersListResult = { + profiles: Record; + lastGood?: Record; + order?: Record; +}; + +export type ProviderActiveResult = { + activeProfile: ProviderProfile | null; + provider: string | null; +}; + +export type ProviderSwitchResult = { + success: boolean; + activeProfile: ProviderProfile; +}; + +export type ProvidersState = { + client: GatewayBrowserClient | null; + connected: boolean; + providersLoading: boolean; + providersError: string | null; + providersList: ProvidersListResult | null; + activeProvider: ProviderProfile | null; + providerSwitching: boolean; +}; + +export async function loadProviders(state: ProvidersState): Promise { + if (!state.client || !state.connected) return; + if (state.providersLoading) return; + state.providersLoading = true; + state.providersError = null; + try { + const res = (await state.client.request("providers.list", {})) as + | ProvidersListResult + | undefined; + if (res) { + state.providersList = res; + // Find active provider + const activeId = Object.keys(res.profiles).find((id) => res.profiles[id].isActive); + state.activeProvider = activeId ? res.profiles[activeId] : null; + } + } catch (err) { + state.providersError = String(err); + } finally { + state.providersLoading = false; + } +} + +export async function switchProvider(state: ProvidersState, profileId: string): Promise { + if (!state.client || !state.connected) return false; + if (state.providerSwitching) return false; + state.providerSwitching = true; + state.providersError = null; + try { + const res = (await state.client.request("providers.switch", { + profileId, + })) as ProviderSwitchResult | undefined; + if (res?.success) { + state.activeProvider = res.activeProfile; + // Reload providers list to update isActive flags + await loadProviders(state); + return true; + } + return false; + } catch (err) { + state.providersError = String(err); + return false; + } finally { + state.providerSwitching = false; + } +} + +export function formatProviderExpiry(expiresAt?: number): string | null { + if (!expiresAt) return null; + const now = Date.now(); + const diff = expiresAt - now; + if (diff <= 0) return "Expired"; + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + if (hours > 24) { + const days = Math.floor(hours / 24); + return `${days}d ${hours % 24}h`; + } + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +export function isProviderExpiringSoon(expiresAt?: number): boolean { + if (!expiresAt) return false; + const now = Date.now(); + const diff = expiresAt - now; + // Expiring within 4 hours + return diff > 0 && diff < 4 * 60 * 60 * 1000; +} diff --git a/ui/src/ui/views/providers.ts b/ui/src/ui/views/providers.ts new file mode 100644 index 000000000..3acd01117 --- /dev/null +++ b/ui/src/ui/views/providers.ts @@ -0,0 +1,207 @@ +import { html, nothing, type TemplateResult } from "lit"; +import type { ProviderProfile, ProvidersListResult } from "../controllers/providers"; +import { formatProviderExpiry, isProviderExpiringSoon } from "../controllers/providers"; + +export type ProviderSwitcherProps = { + connected: boolean; + loading: boolean; + switching: boolean; + error: string | null; + providersList: ProvidersListResult | null; + activeProvider: ProviderProfile | null; + onSwitch: (profileId: string) => void; + onRefresh: () => void; +}; + +function renderProviderOption(profile: ProviderProfile): TemplateResult { + const expiry = formatProviderExpiry(profile.expiresAt); + const expiringSoon = isProviderExpiringSoon(profile.expiresAt); + const typeIcon = profile.type === "oauth" ? "🔐" : profile.type === "api_key" ? "🔑" : "🎫"; + + return html` + + `; +} + +export function renderProviderSwitcher(props: ProviderSwitcherProps): TemplateResult { + if (!props.connected) { + return html` +
Not connected
+ `; + } + + if (props.loading) { + return html` +
Loading providers...
+ `; + } + + if (props.error) { + return html` +
+ ${props.error} + +
+ `; + } + + if (!props.providersList || Object.keys(props.providersList.profiles).length === 0) { + return html` +
+ No providers configured + +
+ `; + } + + const profiles = Object.values(props.providersList.profiles); + const activeProfile = props.activeProvider; + + const handleChange = (e: Event) => { + const select = e.target as HTMLSelectElement; + const profileId = select.value; + if (profileId && profileId !== activeProfile?.id) { + props.onSwitch(profileId); + } + }; + + // Group profiles by provider + const byProvider = profiles.reduce( + (acc, profile) => { + const provider = profile.provider; + if (!acc[provider]) acc[provider] = []; + acc[provider].push(profile); + return acc; + }, + {} as Record, + ); + + const expiryWarning = + activeProfile && isProviderExpiringSoon(activeProfile.expiresAt) + ? html` + ⚠️ + ` + : nothing; + + return html` +
+ + + ${ + props.switching + ? html` + Switching... + ` + : nothing + } + +
+ `; +} + +export function renderProviderSwitcherStyles(): TemplateResult { + return html` + + `; +}