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:
parent
5eff33abe6
commit
8d5101ea1b
@ -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(
|
||||||
|
|||||||
109
src/gateway/server-methods/providers.test.ts
Normal file
109
src/gateway/server-methods/providers.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
205
src/gateway/server-methods/providers.ts
Normal file
205
src/gateway/server-methods/providers.ts
Normal 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)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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,14 +213,13 @@ 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"
|
${
|
||||||
|
state.tab === "overview"
|
||||||
? renderOverview({
|
? renderOverview({
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
hello: state.hello,
|
hello: state.hello,
|
||||||
@ -233,9 +247,11 @@ export function renderApp(state: AppViewState) {
|
|||||||
onConnect: () => state.connect(),
|
onConnect: () => state.connect(),
|
||||||
onRefresh: () => state.loadOverview(),
|
onRefresh: () => state.loadOverview(),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing
|
||||||
|
}
|
||||||
|
|
||||||
${state.tab === "channels"
|
${
|
||||||
|
state.tab === "channels"
|
||||||
? renderChannels({
|
? renderChannels({
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
loading: state.channelsLoading,
|
loading: state.channelsLoading,
|
||||||
@ -270,9 +286,11 @@ export function renderApp(state: AppViewState) {
|
|||||||
onNostrProfileImport: () => state.handleNostrProfileImport(),
|
onNostrProfileImport: () => state.handleNostrProfileImport(),
|
||||||
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
|
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing
|
||||||
|
}
|
||||||
|
|
||||||
${state.tab === "instances"
|
${
|
||||||
|
state.tab === "instances"
|
||||||
? renderInstances({
|
? renderInstances({
|
||||||
loading: state.presenceLoading,
|
loading: state.presenceLoading,
|
||||||
entries: state.presenceEntries,
|
entries: state.presenceEntries,
|
||||||
@ -280,9 +298,11 @@ export function renderApp(state: AppViewState) {
|
|||||||
statusMessage: state.presenceStatus,
|
statusMessage: state.presenceStatus,
|
||||||
onRefresh: () => loadPresence(state),
|
onRefresh: () => loadPresence(state),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing
|
||||||
|
}
|
||||||
|
|
||||||
${state.tab === "sessions"
|
${
|
||||||
|
state.tab === "sessions"
|
||||||
? renderSessions({
|
? renderSessions({
|
||||||
loading: state.sessionsLoading,
|
loading: state.sessionsLoading,
|
||||||
result: state.sessionsResult,
|
result: state.sessionsResult,
|
||||||
@ -302,9 +322,11 @@ export function renderApp(state: AppViewState) {
|
|||||||
onPatch: (key, patch) => patchSession(state, key, patch),
|
onPatch: (key, patch) => patchSession(state, key, patch),
|
||||||
onDelete: (key) => deleteSession(state, key),
|
onDelete: (key) => deleteSession(state, key),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing
|
||||||
|
}
|
||||||
|
|
||||||
${state.tab === "cron"
|
${
|
||||||
|
state.tab === "cron"
|
||||||
? renderCron({
|
? renderCron({
|
||||||
loading: state.cronLoading,
|
loading: state.cronLoading,
|
||||||
status: state.cronStatus,
|
status: state.cronStatus,
|
||||||
@ -314,7 +336,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
form: state.cronForm,
|
form: state.cronForm,
|
||||||
channels: state.channelsSnapshot?.channelMeta?.length
|
channels: state.channelsSnapshot?.channelMeta?.length
|
||||||
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
|
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
|
||||||
: state.channelsSnapshot?.channelOrder ?? [],
|
: (state.channelsSnapshot?.channelOrder ?? []),
|
||||||
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
|
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
|
||||||
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
|
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
|
||||||
runsJobId: state.cronRunsJobId,
|
runsJobId: state.cronRunsJobId,
|
||||||
@ -327,9 +349,11 @@ export function renderApp(state: AppViewState) {
|
|||||||
onRemove: (job) => removeCronJob(state, job),
|
onRemove: (job) => removeCronJob(state, job),
|
||||||
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
|
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing
|
||||||
|
}
|
||||||
|
|
||||||
${state.tab === "skills"
|
${
|
||||||
|
state.tab === "skills"
|
||||||
? renderSkills({
|
? renderSkills({
|
||||||
loading: state.skillsLoading,
|
loading: state.skillsLoading,
|
||||||
report: state.skillsReport,
|
report: state.skillsReport,
|
||||||
@ -346,16 +370,20 @@ export function renderApp(state: AppViewState) {
|
|||||||
onInstall: (skillKey, name, installId) =>
|
onInstall: (skillKey, name, installId) =>
|
||||||
installSkill(state, skillKey, name, installId),
|
installSkill(state, skillKey, name, installId),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing
|
||||||
|
}
|
||||||
|
|
||||||
${state.tab === "nodes"
|
${
|
||||||
|
state.tab === "nodes"
|
||||||
? renderNodes({
|
? renderNodes({
|
||||||
loading: state.nodesLoading,
|
loading: state.nodesLoading,
|
||||||
nodes: state.nodes,
|
nodes: state.nodes,
|
||||||
devicesLoading: state.devicesLoading,
|
devicesLoading: state.devicesLoading,
|
||||||
devicesError: state.devicesError,
|
devicesError: state.devicesError,
|
||||||
devicesList: state.devicesList,
|
devicesList: state.devicesList,
|
||||||
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
|
configForm:
|
||||||
|
state.configForm ??
|
||||||
|
(state.configSnapshot?.config as Record<string, unknown> | null),
|
||||||
configLoading: state.configLoading,
|
configLoading: state.configLoading,
|
||||||
configSaving: state.configSaving,
|
configSaving: state.configSaving,
|
||||||
configDirty: state.configFormDirty,
|
configDirty: state.configFormDirty,
|
||||||
@ -374,8 +402,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
|
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
|
||||||
onDeviceRotate: (deviceId, role, scopes) =>
|
onDeviceRotate: (deviceId, role, scopes) =>
|
||||||
rotateDeviceToken(state, { deviceId, role, scopes }),
|
rotateDeviceToken(state, { deviceId, role, scopes }),
|
||||||
onDeviceRevoke: (deviceId, role) =>
|
onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }),
|
||||||
revokeDeviceToken(state, { deviceId, role }),
|
|
||||||
onLoadConfig: () => loadConfig(state),
|
onLoadConfig: () => loadConfig(state),
|
||||||
onLoadExecApprovals: () => {
|
onLoadExecApprovals: () => {
|
||||||
const target =
|
const target =
|
||||||
@ -413,8 +440,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
},
|
},
|
||||||
onExecApprovalsPatch: (path, value) =>
|
onExecApprovalsPatch: (path, value) =>
|
||||||
updateExecApprovalsFormValue(state, path, value),
|
updateExecApprovalsFormValue(state, path, value),
|
||||||
onExecApprovalsRemove: (path) =>
|
onExecApprovalsRemove: (path) => removeExecApprovalsFormValue(state, path),
|
||||||
removeExecApprovalsFormValue(state, path),
|
|
||||||
onSaveExecApprovals: () => {
|
onSaveExecApprovals: () => {
|
||||||
const target =
|
const target =
|
||||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||||
@ -423,9 +449,11 @@ export function renderApp(state: AppViewState) {
|
|||||||
return saveExecApprovals(state, target);
|
return saveExecApprovals(state, target);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing
|
||||||
|
}
|
||||||
|
|
||||||
${state.tab === "chat"
|
${
|
||||||
|
state.tab === "chat"
|
||||||
? renderChat({
|
? renderChat({
|
||||||
sessionKey: state.sessionKey,
|
sessionKey: state.sessionKey,
|
||||||
onSessionKeyChange: (next) => {
|
onSessionKeyChange: (next) => {
|
||||||
@ -484,8 +512,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
canAbort: Boolean(state.chatRunId),
|
canAbort: Boolean(state.chatRunId),
|
||||||
onAbort: () => void state.handleAbortChat(),
|
onAbort: () => void state.handleAbortChat(),
|
||||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||||
onNewSession: () =>
|
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
|
||||||
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,
|
||||||
@ -497,9 +524,11 @@ export function renderApp(state: AppViewState) {
|
|||||||
assistantName: state.assistantName,
|
assistantName: state.assistantName,
|
||||||
assistantAvatar: state.assistantAvatar,
|
assistantAvatar: state.assistantAvatar,
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing
|
||||||
|
}
|
||||||
|
|
||||||
${state.tab === "config"
|
${
|
||||||
|
state.tab === "config"
|
||||||
? renderConfig({
|
? renderConfig({
|
||||||
raw: state.configRaw,
|
raw: state.configRaw,
|
||||||
originalRaw: state.configRawOriginal,
|
originalRaw: state.configRawOriginal,
|
||||||
@ -535,9 +564,11 @@ export function renderApp(state: AppViewState) {
|
|||||||
onApply: () => applyConfig(state),
|
onApply: () => applyConfig(state),
|
||||||
onUpdate: () => runUpdate(state),
|
onUpdate: () => runUpdate(state),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing
|
||||||
|
}
|
||||||
|
|
||||||
${state.tab === "debug"
|
${
|
||||||
|
state.tab === "debug"
|
||||||
? renderDebug({
|
? renderDebug({
|
||||||
loading: state.debugLoading,
|
loading: state.debugLoading,
|
||||||
status: state.debugStatus,
|
status: state.debugStatus,
|
||||||
@ -554,9 +585,11 @@ export function renderApp(state: AppViewState) {
|
|||||||
onRefresh: () => loadDebug(state),
|
onRefresh: () => loadDebug(state),
|
||||||
onCall: () => callDebugMethod(state),
|
onCall: () => callDebugMethod(state),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing
|
||||||
|
}
|
||||||
|
|
||||||
${state.tab === "logs"
|
${
|
||||||
|
state.tab === "logs"
|
||||||
? renderLogs({
|
? renderLogs({
|
||||||
loading: state.logsLoading,
|
loading: state.logsLoading,
|
||||||
error: state.logsError,
|
error: state.logsError,
|
||||||
@ -575,7 +608,8 @@ export function renderApp(state: AppViewState) {
|
|||||||
onExport: (lines, label) => state.exportLogs(lines, label),
|
onExport: (lines, label) => state.exportLogs(lines, label),
|
||||||
onScroll: (event) => state.handleLogsScroll(event),
|
onScroll: (event) => state.handleLogsScroll(event),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing
|
||||||
|
}
|
||||||
</main>
|
</main>
|
||||||
${renderExecApprovalPrompt(state)}
|
${renderExecApprovalPrompt(state)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
106
ui/src/ui/controllers/providers.ts
Normal file
106
ui/src/ui/controllers/providers.ts
Normal 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;
|
||||||
|
}
|
||||||
207
ui/src/ui/views/providers.ts
Normal file
207
ui/src/ui/views/providers.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user