feat: add provider switcher UI component

Add a dropdown component in the Control UI topbar that allows switching
between configured auth profiles (OAuth, API Key, Token).

Backend: providers.list/active/switch/order gateway methods
Frontend: ProviderSwitcher component with expiry warnings
Tests: Unit tests for provider gateway methods

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Ishi 2026-01-27 17:09:39 +01:00
parent 5eff33abe6
commit 8d5101ea1b
9 changed files with 1075 additions and 423 deletions

View File

@ -13,6 +13,7 @@ import { healthHandlers } from "./server-methods/health.js";
import { logsHandlers } from "./server-methods/logs.js"; import { logsHandlers } from "./server-methods/logs.js";
import { modelsHandlers } from "./server-methods/models.js"; import { modelsHandlers } from "./server-methods/models.js";
import { nodeHandlers } from "./server-methods/nodes.js"; import { nodeHandlers } from "./server-methods/nodes.js";
import { providersHandlers } from "./server-methods/providers.js";
import { sendHandlers } from "./server-methods/send.js"; import { sendHandlers } from "./server-methods/send.js";
import { sessionsHandlers } from "./server-methods/sessions.js"; import { sessionsHandlers } from "./server-methods/sessions.js";
import { skillsHandlers } from "./server-methods/skills.js"; import { skillsHandlers } from "./server-methods/skills.js";
@ -72,6 +73,8 @@ const READ_METHODS = new Set([
"node.list", "node.list",
"node.describe", "node.describe",
"chat.history", "chat.history",
"providers.list",
"providers.active",
]); ]);
const WRITE_METHODS = new Set([ const WRITE_METHODS = new Set([
"send", "send",
@ -138,7 +141,9 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
method === "sessions.patch" || method === "sessions.patch" ||
method === "sessions.reset" || method === "sessions.reset" ||
method === "sessions.delete" || method === "sessions.delete" ||
method === "sessions.compact" method === "sessions.compact" ||
method === "providers.switch" ||
method === "providers.order"
) { ) {
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin");
} }
@ -171,6 +176,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
...agentHandlers, ...agentHandlers,
...agentsHandlers, ...agentsHandlers,
...browserHandlers, ...browserHandlers,
...providersHandlers,
}; };
export async function handleGatewayRequest( export async function handleGatewayRequest(

View File

@ -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();
});
});

View File

@ -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<string, ProviderProfile>;
lastGood?: Record<string, string>;
order?: Record<string, string[]>;
};
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<string, ProviderProfile> {
const result: Record<string, ProviderProfile> = {};
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)));
}
},
};

View File

@ -2,6 +2,7 @@ import { loadChatHistory } from "./controllers/chat";
import { loadDevices } from "./controllers/devices"; import { loadDevices } from "./controllers/devices";
import { loadNodes } from "./controllers/nodes"; import { loadNodes } from "./controllers/nodes";
import { loadAgents } from "./controllers/agents"; import { loadAgents } from "./controllers/agents";
import { loadProviders } from "./controllers/providers";
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway"; import type { GatewayEventFrame, GatewayHelloOk } from "./gateway";
import { GatewayBrowserClient } from "./gateway"; import { GatewayBrowserClient } from "./gateway";
import type { EventLogEntry } from "./app-events"; import type { EventLogEntry } from "./app-events";
@ -10,12 +11,7 @@ import type { Tab } from "./navigation";
import type { UiSettings } from "./storage"; import type { UiSettings } from "./storage";
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream"; import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream";
import { flushChatQueueForEvent } from "./app-chat"; import { flushChatQueueForEvent } from "./app-chat";
import { import { applySettings, loadCron, refreshActiveTab, setLastActiveSessionKey } from "./app-settings";
applySettings,
loadCron,
refreshActiveTab,
setLastActiveSessionKey,
} from "./app-settings";
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat"; import { handleChatEvent, type ChatEventPayload } from "./controllers/chat";
import { import {
addExecApproval, addExecApproval,
@ -75,8 +71,7 @@ function normalizeSessionKeyForDefaults(
raw === "main" || raw === "main" ||
raw === mainKey || raw === mainKey ||
(defaultAgentId && (defaultAgentId &&
(raw === `agent:${defaultAgentId}:main` || (raw === `agent:${defaultAgentId}:main` || raw === `agent:${defaultAgentId}:${mainKey}`));
raw === `agent:${defaultAgentId}:${mainKey}`));
return isAlias ? mainSessionKey : raw; return isAlias ? mainSessionKey : raw;
} }
@ -135,6 +130,7 @@ export function connectGateway(host: GatewayHost) {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]); resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
void loadAssistantIdentity(host as unknown as MoltbotApp); void loadAssistantIdentity(host as unknown as MoltbotApp);
void loadAgents(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 loadNodes(host as unknown as MoltbotApp, { quiet: true });
void loadDevices(host as unknown as MoltbotApp, { quiet: true }); void loadDevices(host as unknown as MoltbotApp, { quiet: true });
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]); void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
@ -191,9 +187,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
const state = handleChatEvent(host as unknown as MoltbotApp, payload); const state = handleChatEvent(host as unknown as MoltbotApp, payload);
if (state === "final" || state === "error" || state === "aborted") { if (state === "final" || state === "error" || state === "aborted") {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]); resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
void flushChatQueueForEvent( void flushChatQueueForEvent(host as unknown as Parameters<typeof flushChatQueueForEvent>[0]);
host as unknown as Parameters<typeof flushChatQueueForEvent>[0],
);
} }
if (state === "final") void loadChatHistory(host as unknown as MoltbotApp); if (state === "final") void loadChatHistory(host as unknown as MoltbotApp);
return; return;

View File

@ -78,9 +78,17 @@ import {
saveExecApprovals, saveExecApprovals,
updateExecApprovalsFormValue, updateExecApprovalsFormValue,
} from "./controllers/exec-approvals"; } 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 { loadDebug, callDebugMethod } from "./controllers/debug";
import { loadLogs } from "./controllers/logs"; import { loadLogs } from "./controllers/logs";
import { renderProviderSwitcher } from "./views/providers";
import { loadProviders, switchProvider } from "./controllers/providers";
const AVATAR_DATA_RE = /^data:/i; const AVATAR_DATA_RE = /^data:/i;
const AVATAR_HTTP_RE = /^https?:\/\//i; const AVATAR_HTTP_RE = /^https?:\/\//i;
@ -88,10 +96,7 @@ const AVATAR_HTTP_RE = /^https?:\/\//i;
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
const list = state.agentsList?.agents ?? []; const list = state.agentsList?.agents ?? [];
const parsed = parseAgentSessionKey(state.sessionKey); const parsed = parseAgentSessionKey(state.sessionKey);
const agentId = const agentId = parsed?.agentId ?? state.agentsList?.defaultId ?? "main";
parsed?.agentId ??
state.agentsList?.defaultId ??
"main";
const agent = list.find((entry) => entry.id === agentId); const agent = list.find((entry) => entry.id === agentId);
const identity = agent?.identity; const identity = agent?.identity;
const candidate = identity?.avatarUrl ?? identity?.avatar; const candidate = identity?.avatarUrl ?? identity?.avatar;
@ -143,6 +148,16 @@ export function renderApp(state: AppViewState) {
<span>Health</span> <span>Health</span>
<span class="mono">${state.connected ? "OK" : "Offline"}</span> <span class="mono">${state.connected ? "OK" : "Offline"}</span>
</div> </div>
${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)} ${renderThemeToggle(state)}
</div> </div>
</header> </header>
@ -198,384 +213,403 @@ export function renderApp(state: AppViewState) {
<div class="page-sub">${subtitleForTab(state.tab)}</div> <div class="page-sub">${subtitleForTab(state.tab)}</div>
</div> </div>
<div class="page-meta"> <div class="page-meta">
${state.lastError ${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
? html`<div class="pill danger">${state.lastError}</div>`
: nothing}
${isChat ? renderChatControls(state) : nothing} ${isChat ? renderChatControls(state) : nothing}
</div> </div>
</section> </section>
${state.tab === "overview" ${
? renderOverview({ state.tab === "overview"
connected: state.connected, ? renderOverview({
hello: state.hello, connected: state.connected,
settings: state.settings, hello: state.hello,
password: state.password, settings: state.settings,
lastError: state.lastError, password: state.password,
presenceCount, lastError: state.lastError,
sessionsCount, presenceCount,
cronEnabled: state.cronStatus?.enabled ?? null, sessionsCount,
cronNext, cronEnabled: state.cronStatus?.enabled ?? null,
lastChannelsRefresh: state.channelsLastSuccess, cronNext,
onSettingsChange: (next) => state.applySettings(next), lastChannelsRefresh: state.channelsLastSuccess,
onPasswordChange: (next) => (state.password = next), onSettingsChange: (next) => state.applySettings(next),
onSessionKeyChange: (next) => { onPasswordChange: (next) => (state.password = next),
state.sessionKey = next; onSessionKeyChange: (next) => {
state.chatMessage = ""; state.sessionKey = next;
state.resetToolStream(); state.chatMessage = "";
state.applySettings({ state.resetToolStream();
...state.settings, state.applySettings({
sessionKey: next, ...state.settings,
lastActiveSessionKey: next, sessionKey: next,
}); lastActiveSessionKey: next,
void state.loadAssistantIdentity(); });
}, void state.loadAssistantIdentity();
onConnect: () => state.connect(), },
onRefresh: () => state.loadOverview(), onConnect: () => state.connect(),
}) onRefresh: () => state.loadOverview(),
: nothing} })
: nothing
}
${state.tab === "channels" ${
? renderChannels({ state.tab === "channels"
connected: state.connected, ? renderChannels({
loading: state.channelsLoading, connected: state.connected,
snapshot: state.channelsSnapshot, loading: state.channelsLoading,
lastError: state.channelsError, snapshot: state.channelsSnapshot,
lastSuccessAt: state.channelsLastSuccess, lastError: state.channelsError,
whatsappMessage: state.whatsappLoginMessage, lastSuccessAt: state.channelsLastSuccess,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl, whatsappMessage: state.whatsappLoginMessage,
whatsappConnected: state.whatsappLoginConnected, whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappBusy: state.whatsappBusy, whatsappConnected: state.whatsappLoginConnected,
configSchema: state.configSchema, whatsappBusy: state.whatsappBusy,
configSchemaLoading: state.configSchemaLoading, configSchema: state.configSchema,
configForm: state.configForm, configSchemaLoading: state.configSchemaLoading,
configUiHints: state.configUiHints, configForm: state.configForm,
configSaving: state.configSaving, configUiHints: state.configUiHints,
configFormDirty: state.configFormDirty, configSaving: state.configSaving,
nostrProfileFormState: state.nostrProfileFormState, configFormDirty: state.configFormDirty,
nostrProfileAccountId: state.nostrProfileAccountId, nostrProfileFormState: state.nostrProfileFormState,
onRefresh: (probe) => loadChannels(state, probe), nostrProfileAccountId: state.nostrProfileAccountId,
onWhatsAppStart: (force) => state.handleWhatsAppStart(force), onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppWait: () => state.handleWhatsAppWait(), onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppLogout: () => state.handleWhatsAppLogout(), onWhatsAppWait: () => state.handleWhatsAppWait(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value), onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigSave: () => state.handleChannelConfigSave(), onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigReload: () => state.handleChannelConfigReload(), onConfigSave: () => state.handleChannelConfigSave(),
onNostrProfileEdit: (accountId, profile) => onConfigReload: () => state.handleChannelConfigReload(),
state.handleNostrProfileEdit(accountId, profile), onNostrProfileEdit: (accountId, profile) =>
onNostrProfileCancel: () => state.handleNostrProfileCancel(), state.handleNostrProfileEdit(accountId, profile),
onNostrProfileFieldChange: (field, value) => onNostrProfileCancel: () => state.handleNostrProfileCancel(),
state.handleNostrProfileFieldChange(field, value), onNostrProfileFieldChange: (field, value) =>
onNostrProfileSave: () => state.handleNostrProfileSave(), state.handleNostrProfileFieldChange(field, value),
onNostrProfileImport: () => state.handleNostrProfileImport(), onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(), onNostrProfileImport: () => state.handleNostrProfileImport(),
}) onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
: nothing} })
: nothing
}
${state.tab === "instances" ${
? renderInstances({ state.tab === "instances"
loading: state.presenceLoading, ? renderInstances({
entries: state.presenceEntries, loading: state.presenceLoading,
lastError: state.presenceError, entries: state.presenceEntries,
statusMessage: state.presenceStatus, lastError: state.presenceError,
onRefresh: () => loadPresence(state), statusMessage: state.presenceStatus,
}) onRefresh: () => loadPresence(state),
: nothing} })
: nothing
}
${state.tab === "sessions" ${
? renderSessions({ state.tab === "sessions"
loading: state.sessionsLoading, ? renderSessions({
result: state.sessionsResult, loading: state.sessionsLoading,
error: state.sessionsError, result: state.sessionsResult,
activeMinutes: state.sessionsFilterActive, error: state.sessionsError,
limit: state.sessionsFilterLimit, activeMinutes: state.sessionsFilterActive,
includeGlobal: state.sessionsIncludeGlobal, limit: state.sessionsFilterLimit,
includeUnknown: state.sessionsIncludeUnknown, includeGlobal: state.sessionsIncludeGlobal,
basePath: state.basePath, includeUnknown: state.sessionsIncludeUnknown,
onFiltersChange: (next) => { basePath: state.basePath,
state.sessionsFilterActive = next.activeMinutes; onFiltersChange: (next) => {
state.sessionsFilterLimit = next.limit; state.sessionsFilterActive = next.activeMinutes;
state.sessionsIncludeGlobal = next.includeGlobal; state.sessionsFilterLimit = next.limit;
state.sessionsIncludeUnknown = next.includeUnknown; state.sessionsIncludeGlobal = next.includeGlobal;
}, state.sessionsIncludeUnknown = next.includeUnknown;
onRefresh: () => loadSessions(state), },
onPatch: (key, patch) => patchSession(state, key, patch), onRefresh: () => loadSessions(state),
onDelete: (key) => deleteSession(state, key), onPatch: (key, patch) => patchSession(state, key, patch),
}) onDelete: (key) => deleteSession(state, key),
: nothing} })
: nothing
}
${state.tab === "cron" ${
? renderCron({ state.tab === "cron"
loading: state.cronLoading, ? renderCron({
status: state.cronStatus, loading: state.cronLoading,
jobs: state.cronJobs, status: state.cronStatus,
error: state.cronError, jobs: state.cronJobs,
busy: state.cronBusy, error: state.cronError,
form: state.cronForm, busy: state.cronBusy,
channels: state.channelsSnapshot?.channelMeta?.length form: state.cronForm,
? state.channelsSnapshot.channelMeta.map((entry) => entry.id) channels: state.channelsSnapshot?.channelMeta?.length
: state.channelsSnapshot?.channelOrder ?? [], ? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
channelLabels: state.channelsSnapshot?.channelLabels ?? {}, : (state.channelsSnapshot?.channelOrder ?? []),
channelMeta: state.channelsSnapshot?.channelMeta ?? [], channelLabels: state.channelsSnapshot?.channelLabels ?? {},
runsJobId: state.cronRunsJobId, channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runs: state.cronRuns, runsJobId: state.cronRunsJobId,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }), runs: state.cronRuns,
onRefresh: () => state.loadCron(), onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onAdd: () => addCronJob(state), onRefresh: () => state.loadCron(),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled), onAdd: () => addCronJob(state),
onRun: (job) => runCronJob(state, job), onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRemove: (job) => removeCronJob(state, job), onRun: (job) => runCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId), onRemove: (job) => removeCronJob(state, job),
}) onLoadRuns: (jobId) => loadCronRuns(state, jobId),
: nothing} })
: nothing
}
${state.tab === "skills" ${
? renderSkills({ state.tab === "skills"
loading: state.skillsLoading, ? renderSkills({
report: state.skillsReport, loading: state.skillsLoading,
error: state.skillsError, report: state.skillsReport,
filter: state.skillsFilter, error: state.skillsError,
edits: state.skillEdits, filter: state.skillsFilter,
messages: state.skillMessages, edits: state.skillEdits,
busyKey: state.skillsBusyKey, messages: state.skillMessages,
onFilterChange: (next) => (state.skillsFilter = next), busyKey: state.skillsBusyKey,
onRefresh: () => loadSkills(state, { clearMessages: true }), onFilterChange: (next) => (state.skillsFilter = next),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled), onRefresh: () => loadSkills(state, { clearMessages: true }),
onEdit: (key, value) => updateSkillEdit(state, key, value), onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onSaveKey: (key) => saveSkillApiKey(state, key), onEdit: (key, value) => updateSkillEdit(state, key, value),
onInstall: (skillKey, name, installId) => onSaveKey: (key) => saveSkillApiKey(state, key),
installSkill(state, skillKey, name, installId), onInstall: (skillKey, name, installId) =>
}) installSkill(state, skillKey, name, installId),
: nothing} })
: nothing
}
${state.tab === "nodes" ${
? renderNodes({ state.tab === "nodes"
loading: state.nodesLoading, ? renderNodes({
nodes: state.nodes, loading: state.nodesLoading,
devicesLoading: state.devicesLoading, nodes: state.nodes,
devicesError: state.devicesError, devicesLoading: state.devicesLoading,
devicesList: state.devicesList, devicesError: state.devicesError,
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null), devicesList: state.devicesList,
configLoading: state.configLoading, configForm:
configSaving: state.configSaving, state.configForm ??
configDirty: state.configFormDirty, (state.configSnapshot?.config as Record<string, unknown> | null),
configFormMode: state.configFormMode, configLoading: state.configLoading,
execApprovalsLoading: state.execApprovalsLoading, configSaving: state.configSaving,
execApprovalsSaving: state.execApprovalsSaving, configDirty: state.configFormDirty,
execApprovalsDirty: state.execApprovalsDirty, configFormMode: state.configFormMode,
execApprovalsSnapshot: state.execApprovalsSnapshot, execApprovalsLoading: state.execApprovalsLoading,
execApprovalsForm: state.execApprovalsForm, execApprovalsSaving: state.execApprovalsSaving,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent, execApprovalsDirty: state.execApprovalsDirty,
execApprovalsTarget: state.execApprovalsTarget, execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId, execApprovalsForm: state.execApprovalsForm,
onRefresh: () => loadNodes(state), execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
onDevicesRefresh: () => loadDevices(state), execApprovalsTarget: state.execApprovalsTarget,
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId), execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId), onRefresh: () => loadNodes(state),
onDeviceRotate: (deviceId, role, scopes) => onDevicesRefresh: () => loadDevices(state),
rotateDeviceToken(state, { deviceId, role, scopes }), onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceRevoke: (deviceId, role) => onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
revokeDeviceToken(state, { deviceId, role }), onDeviceRotate: (deviceId, role, scopes) =>
onLoadConfig: () => loadConfig(state), rotateDeviceToken(state, { deviceId, role, scopes }),
onLoadExecApprovals: () => { onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }),
const target = onLoadConfig: () => loadConfig(state),
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId onLoadExecApprovals: () => {
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } const target =
: { kind: "gateway" as const }; state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
return loadExecApprovals(state, target); ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
}, : { kind: "gateway" as const };
onBindDefault: (nodeId) => { return loadExecApprovals(state, target);
if (nodeId) { },
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); onBindDefault: (nodeId) => {
} else { if (nodeId) {
removeConfigFormValue(state, ["tools", "exec", "node"]); 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) { onBindAgent: (agentIndex, nodeId) => {
updateConfigFormValue(state, basePath, nodeId); const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
} else { if (nodeId) {
removeConfigFormValue(state, basePath); updateConfigFormValue(state, basePath, nodeId);
} } else {
}, removeConfigFormValue(state, basePath);
onSaveBindings: () => saveConfig(state), }
onExecApprovalsTargetChange: (kind, nodeId) => { },
state.execApprovalsTarget = kind; onSaveBindings: () => saveConfig(state),
state.execApprovalsTargetNodeId = nodeId; onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsSnapshot = null; state.execApprovalsTarget = kind;
state.execApprovalsForm = null; state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsDirty = false; state.execApprovalsSnapshot = null;
state.execApprovalsSelectedAgent = null; state.execApprovalsForm = null;
}, state.execApprovalsDirty = false;
onExecApprovalsSelectAgent: (agentId) => { state.execApprovalsSelectedAgent = null;
state.execApprovalsSelectedAgent = agentId; },
}, onExecApprovalsSelectAgent: (agentId) => {
onExecApprovalsPatch: (path, value) => state.execApprovalsSelectedAgent = agentId;
updateExecApprovalsFormValue(state, path, value), },
onExecApprovalsRemove: (path) => onExecApprovalsPatch: (path, value) =>
removeExecApprovalsFormValue(state, path), updateExecApprovalsFormValue(state, path, value),
onSaveExecApprovals: () => { onExecApprovalsRemove: (path) => removeExecApprovalsFormValue(state, path),
const target = onSaveExecApprovals: () => {
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId const target =
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
: { kind: "gateway" as const }; ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
return saveExecApprovals(state, target); : { kind: "gateway" as const };
}, return saveExecApprovals(state, target);
}) },
: nothing} })
: nothing
}
${state.tab === "chat" ${
? renderChat({ state.tab === "chat"
sessionKey: state.sessionKey, ? renderChat({
onSessionKeyChange: (next) => { sessionKey: state.sessionKey,
state.sessionKey = next; onSessionKeyChange: (next) => {
state.chatMessage = ""; state.sessionKey = next;
state.chatAttachments = []; state.chatMessage = "";
state.chatStream = null; state.chatAttachments = [];
state.chatStreamStartedAt = null; state.chatStream = null;
state.chatRunId = null; state.chatStreamStartedAt = null;
state.chatQueue = []; state.chatRunId = null;
state.resetToolStream(); state.chatQueue = [];
state.resetChatScroll(); state.resetToolStream();
state.applySettings({ state.resetChatScroll();
...state.settings, state.applySettings({
sessionKey: next, ...state.settings,
lastActiveSessionKey: next, sessionKey: next,
}); lastActiveSessionKey: next,
void state.loadAssistantIdentity(); });
void loadChatHistory(state); void state.loadAssistantIdentity();
void refreshChatAvatar(state); void loadChatHistory(state);
}, void refreshChatAvatar(state);
thinkingLevel: state.chatThinkingLevel, },
showThinking, thinkingLevel: state.chatThinkingLevel,
loading: state.chatLoading, showThinking,
sending: state.chatSending, loading: state.chatLoading,
compactionStatus: state.compactionStatus, sending: state.chatSending,
assistantAvatarUrl: chatAvatarUrl, compactionStatus: state.compactionStatus,
messages: state.chatMessages, assistantAvatarUrl: chatAvatarUrl,
toolMessages: state.chatToolMessages, messages: state.chatMessages,
stream: state.chatStream, toolMessages: state.chatToolMessages,
streamStartedAt: state.chatStreamStartedAt, stream: state.chatStream,
draft: state.chatMessage, streamStartedAt: state.chatStreamStartedAt,
queue: state.chatQueue, draft: state.chatMessage,
connected: state.connected, queue: state.chatQueue,
canSend: state.connected, connected: state.connected,
disabledReason: chatDisabledReason, canSend: state.connected,
error: state.lastError, disabledReason: chatDisabledReason,
sessions: state.sessionsResult, error: state.lastError,
focusMode: chatFocus, sessions: state.sessionsResult,
onRefresh: () => { focusMode: chatFocus,
state.resetToolStream(); onRefresh: () => {
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); state.resetToolStream();
}, return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
onToggleFocusMode: () => { },
if (state.onboarding) return; onToggleFocusMode: () => {
state.applySettings({ if (state.onboarding) return;
...state.settings, state.applySettings({
chatFocusMode: !state.settings.chatFocusMode, ...state.settings,
}); chatFocusMode: !state.settings.chatFocusMode,
}, });
onChatScroll: (event) => state.handleChatScroll(event), },
onDraftChange: (next) => (state.chatMessage = next), onChatScroll: (event) => state.handleChatScroll(event),
attachments: state.chatAttachments, onDraftChange: (next) => (state.chatMessage = next),
onAttachmentsChange: (next) => (state.chatAttachments = next), attachments: state.chatAttachments,
onSend: () => state.handleSendChat(), onAttachmentsChange: (next) => (state.chatAttachments = next),
canAbort: Boolean(state.chatRunId), onSend: () => state.handleSendChat(),
onAbort: () => void state.handleAbortChat(), canAbort: Boolean(state.chatRunId),
onQueueRemove: (id) => state.removeQueuedMessage(id), onAbort: () => void state.handleAbortChat(),
onNewSession: () => onQueueRemove: (id) => state.removeQueuedMessage(id),
state.handleSendChat("/new", { restoreDraft: true }), onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
// Sidebar props for tool output viewing // Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen, sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent, sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError, sidebarError: state.sidebarError,
splitRatio: state.splitRatio, splitRatio: state.splitRatio,
onOpenSidebar: (content: string) => state.handleOpenSidebar(content), onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(), onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName, assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar, assistantAvatar: state.assistantAvatar,
}) })
: nothing} : nothing
}
${state.tab === "config" ${
? renderConfig({ state.tab === "config"
raw: state.configRaw, ? renderConfig({
originalRaw: state.configRawOriginal, raw: state.configRaw,
valid: state.configValid, originalRaw: state.configRawOriginal,
issues: state.configIssues, valid: state.configValid,
loading: state.configLoading, issues: state.configIssues,
saving: state.configSaving, loading: state.configLoading,
applying: state.configApplying, saving: state.configSaving,
updating: state.updateRunning, applying: state.configApplying,
connected: state.connected, updating: state.updateRunning,
schema: state.configSchema, connected: state.connected,
schemaLoading: state.configSchemaLoading, schema: state.configSchema,
uiHints: state.configUiHints, schemaLoading: state.configSchemaLoading,
formMode: state.configFormMode, uiHints: state.configUiHints,
formValue: state.configForm, formMode: state.configFormMode,
originalValue: state.configFormOriginal, formValue: state.configForm,
searchQuery: state.configSearchQuery, originalValue: state.configFormOriginal,
activeSection: state.configActiveSection, searchQuery: state.configSearchQuery,
activeSubsection: state.configActiveSubsection, activeSection: state.configActiveSection,
onRawChange: (next) => { activeSubsection: state.configActiveSubsection,
state.configRaw = next; onRawChange: (next) => {
}, state.configRaw = next;
onFormModeChange: (mode) => (state.configFormMode = mode), },
onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onFormModeChange: (mode) => (state.configFormMode = mode),
onSearchChange: (query) => (state.configSearchQuery = query), onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSectionChange: (section) => { onSearchChange: (query) => (state.configSearchQuery = query),
state.configActiveSection = section; onSectionChange: (section) => {
state.configActiveSubsection = null; state.configActiveSection = section;
}, state.configActiveSubsection = null;
onSubsectionChange: (section) => (state.configActiveSubsection = section), },
onReload: () => loadConfig(state), onSubsectionChange: (section) => (state.configActiveSubsection = section),
onSave: () => saveConfig(state), onReload: () => loadConfig(state),
onApply: () => applyConfig(state), onSave: () => saveConfig(state),
onUpdate: () => runUpdate(state), onApply: () => applyConfig(state),
}) onUpdate: () => runUpdate(state),
: nothing} })
: nothing
}
${state.tab === "debug" ${
? renderDebug({ state.tab === "debug"
loading: state.debugLoading, ? renderDebug({
status: state.debugStatus, loading: state.debugLoading,
health: state.debugHealth, status: state.debugStatus,
models: state.debugModels, health: state.debugHealth,
heartbeat: state.debugHeartbeat, models: state.debugModels,
eventLog: state.eventLog, heartbeat: state.debugHeartbeat,
callMethod: state.debugCallMethod, eventLog: state.eventLog,
callParams: state.debugCallParams, callMethod: state.debugCallMethod,
callResult: state.debugCallResult, callParams: state.debugCallParams,
callError: state.debugCallError, callResult: state.debugCallResult,
onCallMethodChange: (next) => (state.debugCallMethod = next), callError: state.debugCallError,
onCallParamsChange: (next) => (state.debugCallParams = next), onCallMethodChange: (next) => (state.debugCallMethod = next),
onRefresh: () => loadDebug(state), onCallParamsChange: (next) => (state.debugCallParams = next),
onCall: () => callDebugMethod(state), onRefresh: () => loadDebug(state),
}) onCall: () => callDebugMethod(state),
: nothing} })
: nothing
}
${state.tab === "logs" ${
? renderLogs({ state.tab === "logs"
loading: state.logsLoading, ? renderLogs({
error: state.logsError, loading: state.logsLoading,
file: state.logsFile, error: state.logsError,
entries: state.logsEntries, file: state.logsFile,
filterText: state.logsFilterText, entries: state.logsEntries,
levelFilters: state.logsLevelFilters, filterText: state.logsFilterText,
autoFollow: state.logsAutoFollow, levelFilters: state.logsLevelFilters,
truncated: state.logsTruncated, autoFollow: state.logsAutoFollow,
onFilterTextChange: (next) => (state.logsFilterText = next), truncated: state.logsTruncated,
onLevelToggle: (level, enabled) => { onFilterTextChange: (next) => (state.logsFilterText = next),
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled }; onLevelToggle: (level, enabled) => {
}, state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
onToggleAutoFollow: (next) => (state.logsAutoFollow = next), },
onRefresh: () => loadLogs(state, { reset: true }), onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onExport: (lines, label) => state.exportLogs(lines, label), onRefresh: () => loadLogs(state, { reset: true }),
onScroll: (event) => state.handleLogsScroll(event), onExport: (lines, label) => state.exportLogs(lines, label),
}) onScroll: (event) => state.handleLogsScroll(event),
: nothing} })
: nothing
}
</main> </main>
${renderExecApprovalPrompt(state)} ${renderExecApprovalPrompt(state)}
</div> </div>

View File

@ -22,10 +22,7 @@ import type {
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types"; import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events"; import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills"; import type { SkillMessage } from "./controllers/skills";
import type { import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals";
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices"; import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval"; import type { ExecApprovalRequest } from "./controllers/exec-approval";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form"; import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
@ -106,6 +103,11 @@ export type AppViewState = {
agentsLoading: boolean; agentsLoading: boolean;
agentsList: AgentsListResult | null; agentsList: AgentsListResult | null;
agentsError: string | 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; sessionsLoading: boolean;
sessionsResult: SessionsListResult | null; sessionsResult: SessionsListResult | null;
sessionsError: string | null; sessionsError: string | null;

View File

@ -27,10 +27,7 @@ import type {
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types"; import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events"; import type { EventLogEntry } from "./app-events";
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults"; import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
import type { import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals";
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices"; import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval"; import type { ExecApprovalRequest } from "./controllers/exec-approval";
import { import {
@ -195,6 +192,12 @@ export class MoltbotApp extends LitElement {
@state() agentsList: AgentsListResult | null = null; @state() agentsList: AgentsListResult | null = null;
@state() agentsError: string | 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() sessionsLoading = false;
@state() sessionsResult: SessionsListResult | null = null; @state() sessionsResult: SessionsListResult | null = null;
@state() sessionsError: string | null = null; @state() sessionsError: string | null = null;
@ -259,9 +262,7 @@ export class MoltbotApp extends LitElement {
private toolStreamOrder: string[] = []; private toolStreamOrder: string[] = [];
basePath = ""; basePath = "";
private popStateHandler = () => private popStateHandler = () =>
onPopStateInternal( onPopStateInternal(this as unknown as Parameters<typeof onPopStateInternal>[0]);
this as unknown as Parameters<typeof onPopStateInternal>[0],
);
private themeMedia: MediaQueryList | null = null; private themeMedia: MediaQueryList | null = null;
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null; private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
private topbarObserver: ResizeObserver | null = null; private topbarObserver: ResizeObserver | null = null;
@ -285,16 +286,11 @@ export class MoltbotApp extends LitElement {
} }
protected updated(changed: Map<PropertyKey, unknown>) { protected updated(changed: Map<PropertyKey, unknown>) {
handleUpdated( handleUpdated(this as unknown as Parameters<typeof handleUpdated>[0], changed);
this as unknown as Parameters<typeof handleUpdated>[0],
changed,
);
} }
connect() { connect() {
connectGatewayInternal( connectGatewayInternal(this as unknown as Parameters<typeof connectGatewayInternal>[0]);
this as unknown as Parameters<typeof connectGatewayInternal>[0],
);
} }
handleChatScroll(event: Event) { handleChatScroll(event: Event) {
@ -316,15 +312,11 @@ export class MoltbotApp extends LitElement {
} }
resetToolStream() { resetToolStream() {
resetToolStreamInternal( resetToolStreamInternal(this as unknown as Parameters<typeof resetToolStreamInternal>[0]);
this as unknown as Parameters<typeof resetToolStreamInternal>[0],
);
} }
resetChatScroll() { resetChatScroll() {
resetChatScrollInternal( resetChatScrollInternal(this as unknown as Parameters<typeof resetChatScrollInternal>[0]);
this as unknown as Parameters<typeof resetChatScrollInternal>[0],
);
} }
async loadAssistantIdentity() { async loadAssistantIdentity() {
@ -332,10 +324,7 @@ export class MoltbotApp extends LitElement {
} }
applySettings(next: UiSettings) { applySettings(next: UiSettings) {
applySettingsInternal( applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], next);
this as unknown as Parameters<typeof applySettingsInternal>[0],
next,
);
} }
setTab(next: Tab) { setTab(next: Tab) {
@ -343,29 +332,29 @@ export class MoltbotApp extends LitElement {
} }
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) { setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal( setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
this as unknown as Parameters<typeof setThemeInternal>[0],
next,
context,
);
} }
async loadOverview() { async loadOverview() {
await loadOverviewInternal( await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
this as unknown as Parameters<typeof loadOverviewInternal>[0],
);
} }
async loadCron() { async loadCron() {
await loadCronInternal( await loadCronInternal(this as unknown as Parameters<typeof loadCronInternal>[0]);
this as unknown as Parameters<typeof loadCronInternal>[0], }
);
async loadProviders() {
const { loadProviders } = await import("./controllers/providers.js");
await loadProviders(this as unknown as Parameters<typeof loadProviders>[0]);
}
async switchProvider(profileId: string) {
const { switchProvider } = await import("./controllers/providers.js");
await switchProvider(this as unknown as Parameters<typeof switchProvider>[0], profileId);
} }
async handleAbortChat() { async handleAbortChat() {
await handleAbortChatInternal( await handleAbortChatInternal(this as unknown as Parameters<typeof handleAbortChatInternal>[0]);
this as unknown as Parameters<typeof handleAbortChatInternal>[0],
);
} }
removeQueuedMessage(id: string) { removeQueuedMessage(id: string) {

View File

@ -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<string, ProviderProfile>;
lastGood?: Record<string, string>;
order?: Record<string, string[]>;
};
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<void> {
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<boolean> {
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;
}

View File

@ -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`
<option value="${profile.id}" ?selected=${profile.isActive}>
${typeIcon} ${profile.label}${expiry ? ` (${expiry})` : ""}${expiringSoon ? " ⚠️" : ""}
</option>
`;
}
export function renderProviderSwitcher(props: ProviderSwitcherProps): TemplateResult {
if (!props.connected) {
return html`
<div class="provider-switcher muted">Not connected</div>
`;
}
if (props.loading) {
return html`
<div class="provider-switcher">Loading providers...</div>
`;
}
if (props.error) {
return html`
<div class="provider-switcher error">
<span class="error-text">${props.error}</span>
<button class="btn btn-sm" @click=${props.onRefresh}>Retry</button>
</div>
`;
}
if (!props.providersList || Object.keys(props.providersList.profiles).length === 0) {
return html`
<div class="provider-switcher muted">
No providers configured
<button class="btn btn-sm" @click=${props.onRefresh} title="Refresh providers"></button>
</div>
`;
}
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<string, ProviderProfile[]>,
);
const expiryWarning =
activeProfile && isProviderExpiringSoon(activeProfile.expiresAt)
? html`
<span class="expiry-warning" title="Token expiring soon"></span>
`
: nothing;
return html`
<div class="provider-switcher">
<label class="provider-label">
<span class="provider-label-text">Provider:</span>
${expiryWarning}
</label>
<select
class="provider-select"
@change=${handleChange}
?disabled=${props.switching}
title="Switch AI provider"
>
${Object.entries(byProvider).map(
([provider, providerProfiles]) => html`
<optgroup label="${provider}">
${providerProfiles.map((p) => renderProviderOption(p))}
</optgroup>
`,
)}
</select>
${
props.switching
? html`
<span class="provider-switching">Switching...</span>
`
: nothing
}
<button
class="btn btn-sm provider-refresh"
@click=${props.onRefresh}
title="Refresh provider list"
?disabled=${props.switching}
>
</button>
</div>
`;
}
export function renderProviderSwitcherStyles(): TemplateResult {
return html`
<style>
.provider-switcher {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--surface-1);
border-radius: 6px;
font-size: 13px;
}
.provider-label {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-2);
}
.provider-label-text {
font-weight: 500;
}
.provider-select {
flex: 1;
min-width: 200px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-2);
color: var(--text-1);
font-size: 13px;
cursor: pointer;
}
.provider-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.provider-select:focus {
outline: none;
border-color: var(--accent);
}
.provider-switching {
color: var(--text-2);
font-size: 12px;
}
.provider-refresh {
padding: 4px 8px;
min-width: auto;
}
.expiry-warning {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.provider-switcher.error {
border: 1px solid var(--error);
}
.error-text {
color: var(--error);
flex: 1;
}
</style>
`;
}