Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 96 additions & 3 deletions apps/desktop/electron/app-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
toSessionQueuedMessages,
toSessionRef,
} from "./app-store-utils";
import type { CustomProviderConfig } from "../src/ipc";
import { resolveRepoWorkspaceId } from "../src/workspace-roots";
import { SessionStateMap, type QueuedComposerEditState } from "./session-state-map";
import { createEmptyExtensionUiState, serializeExtensionUiState } from "./session-state-map";
Expand Down Expand Up @@ -482,7 +483,7 @@ export class DesktopAppStore implements AppStoreInternals {
await this.initialize();
const normalizedShell = integratedTerminalShell.trim();
if (this.state.integratedTerminalShell === normalizedShell) {
return this.emit();
return structuredClone(this.state);
}
this.state = {
...this.state,
Expand Down Expand Up @@ -648,6 +649,42 @@ export class DesktopAppStore implements AppStoreInternals {
);
}

async listCustomProviders(): Promise<readonly CustomProviderConfig[]> {
await this.initialize();
const entries = await this.driver.runtimeSupervisor.listCustomProviders();
return entries.map((entry) => ({
providerId: entry.providerId,
baseUrl: entry.baseUrl,
...(entry.apiKey !== undefined ? { apiKey: entry.apiKey } : {}),
models: entry.models.map((model) => ({
id: model.id,
...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}),
})),
}));
}

async setCustomProvider(workspaceId: string, config: CustomProviderConfig): Promise<DesktopAppState> {
return this.withRuntimeUpdate(workspaceId, (ws) =>
this.driver.runtimeSupervisor.setCustomProvider(ws, {
providerId: config.providerId,
baseUrl: config.baseUrl,
...(config.apiKey !== undefined ? { apiKey: config.apiKey } : {}),
models: config.models.map((model) => ({
id: model.id,
...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}),
})),
}),
{ refreshAllWorkspaces: true },
);
}

async deleteCustomProvider(workspaceId: string, providerId: string): Promise<DesktopAppState> {
return this.withRuntimeUpdate(workspaceId, (ws) =>
this.driver.runtimeSupervisor.deleteCustomProvider(ws, providerId),
{ refreshAllWorkspaces: true },
);
}

async setEnableSkillCommands(workspaceId: string, enabled: boolean): Promise<DesktopAppState> {
return this.withRuntimeUpdate(workspaceId, (ws) =>
this.driver.runtimeSupervisor.setEnableSkillCommands(ws, enabled),
Expand Down Expand Up @@ -739,6 +776,7 @@ export class DesktopAppStore implements AppStoreInternals {
action: (ws: WorkspaceRef) => Promise<RuntimeSnapshot>,
options?: {
readonly reloadSessions?: boolean;
readonly refreshAllWorkspaces?: boolean;
},
): Promise<DesktopAppState> {
await this.initialize();
Expand All @@ -749,16 +787,71 @@ export class DesktopAppStore implements AppStoreInternals {

return this.withErrorHandling(async () => {
const snapshot = await action(ws);
this.runtimeByWorkspace.set(workspaceId, snapshot);
if (options?.refreshAllWorkspaces) {
await this.refreshRuntimeForAllWorkspaces(workspaceId, snapshot);
} else {
this.runtimeByWorkspace.set(workspaceId, snapshot);
}
if (options?.reloadSessions) {
this.clearExtensionUiForWorkspace(workspaceId);
await this.reloadSessionsForWorkspace(workspaceId);
}
await this.refreshSessionCommandsForWorkspace(workspaceId);
if (options?.refreshAllWorkspaces) {
await this.refreshSessionCommandsForAllWorkspaces();
} else {
await this.refreshSessionCommandsForWorkspace(workspaceId);
}
return this.refreshState({ clearLastError: true });
});
}

private async refreshRuntimeForAllWorkspaces(
updatedWorkspaceId: string,
updatedSnapshot: RuntimeSnapshot,
): Promise<void> {
this.runtimeByWorkspace.set(updatedWorkspaceId, updatedSnapshot);
const workspacesToRefresh = this.state.workspaces.filter((workspace) => workspace.id !== updatedWorkspaceId);
const snapshots = await Promise.allSettled(
workspacesToRefresh.map(async (workspace) => {
const runtime = await this.driver.runtimeSupervisor.refreshRuntime({
workspaceId: workspace.id,
path: workspace.path,
displayName: workspace.name,
});
return [workspace, runtime] as const;
}),
);
snapshots.forEach((result, index) => {
const workspace = workspacesToRefresh[index];
if (result.status === "fulfilled") {
this.runtimeByWorkspace.set(result.value[0].id, result.value[1]);
return;
}
console.warn(
`[pi-gui] Failed to refresh runtime for ${workspace?.path ?? "unknown workspace"} after custom provider update: ${
result.reason instanceof Error ? result.reason.message : String(result.reason)
}`,
);
});
}

private async refreshSessionCommandsForAllWorkspaces(): Promise<void> {
const results = await Promise.allSettled(
this.state.workspaces.map((workspace) => this.refreshSessionCommandsForWorkspace(workspace.id)),
);
results.forEach((result, index) => {
if (result.status === "fulfilled") {
return;
}
const workspace = this.state.workspaces[index];
console.warn(
`[pi-gui] Failed to refresh session commands for ${workspace?.path ?? "unknown workspace"} after custom provider update: ${
result.reason instanceof Error ? result.reason.message : String(result.reason)
}`,
);
});
}

/* ── Internal infrastructure (AppStoreInternals) ───────── */

private async initializeInternal(): Promise<void> {
Expand Down
65 changes: 64 additions & 1 deletion apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import {
ipcMain,
Menu,
nativeImage,
net,
shell,
type MenuItemConstructorOptions,
type MessageBoxOptions,
} from "electron";
import { isValidHttpBaseUrl } from "@pi-gui/pi-sdk-driver";
import { randomUUID } from "node:crypto";
import { readFile, stat } from "node:fs/promises";
import { readFileSync } from "node:fs";
Expand All @@ -27,7 +29,13 @@ import { checkForUpdate, initUpdateChecker } from "./update-checker";
import { ThemeManager } from "./theme-manager";
import { TerminalService } from "./terminal-service";
import type { DesktopAppState, ThemeMode } from "../src/desktop-state";
import { desktopIpc, getDesktopCommandFromShortcut } from "../src/ipc";
import {
desktopIpc,
getDesktopCommandFromShortcut,
type CustomProviderConfig,
type CustomProviderProbeInput,
type CustomProviderProbeResult,
} from "../src/ipc";
import { SUPPORTED_COMPOSER_IMAGE_TYPES } from "../src/composer-attachments";
import type {
ComposerAttachment,
Expand Down Expand Up @@ -572,6 +580,16 @@ app.whenReady().then(async () => {
ipcMain.handle(desktopIpc.setProviderApiKey, (_event, workspaceId: string, providerId: string, apiKey: string) =>
store.setProviderApiKey(workspaceId, providerId, apiKey),
);
ipcMain.handle(desktopIpc.listCustomProviders, () => store.listCustomProviders());
ipcMain.handle(desktopIpc.setCustomProvider, (_event, workspaceId: string, config: CustomProviderConfig) =>
store.setCustomProvider(workspaceId, config),
);
ipcMain.handle(desktopIpc.deleteCustomProvider, (_event, workspaceId: string, providerId: string) =>
store.deleteCustomProvider(workspaceId, providerId),
);
ipcMain.handle(desktopIpc.probeCustomProviderModels, (_event, input: CustomProviderProbeInput) =>
probeCustomProviderModels(input),
);
ipcMain.handle(desktopIpc.setEnableSkillCommands, (_event, workspaceId: string, enabled: boolean) =>
store.setEnableSkillCommands(workspaceId, enabled),
);
Expand Down Expand Up @@ -927,3 +945,48 @@ async function promptForText(message: string, placeholder = ""): Promise<string>
}
return result.trim();
}

async function probeCustomProviderModels(input: CustomProviderProbeInput): Promise<CustomProviderProbeResult> {
const baseUrl = input.baseUrl?.trim();
if (!baseUrl || !isValidHttpBaseUrl(baseUrl)) {
return { ok: false, error: "Base URL must start with http:// or https://" };
}
const target = `${baseUrl.replace(/\/+$/, "")}/models`;
const apiKey = input.apiKey?.trim();
try {
const response = await net.fetch(target, {
method: "GET",
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return { ok: false, error: `${response.status} ${response.statusText} from ${target}` };
}
const payload = (await response.json()) as unknown;
const data = (payload as { data?: unknown }).data;
if (!Array.isArray(data)) {
return { ok: false, error: `Response from ${target} is missing a "data" array` };
}
const models = data
.map((entry) => {
if (entry && typeof entry === "object" && typeof (entry as { id?: unknown }).id === "string") {
return (entry as { id: string }).id;
}
return undefined;
})
.filter((id): id is string => Boolean(id && id.length > 0));
return { ok: true, models };
} catch (error) {
return { ok: false, error: describeProbeError(error, target) };
}
}

function describeProbeError(error: unknown, target: string): string {
if (error instanceof Error && error.name === "TimeoutError") {
return `Timed out after 5s contacting ${target}`;
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}
11 changes: 11 additions & 0 deletions apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { contextBridge, ipcRenderer, webUtils } from "electron";
import { PRELOAD_DEV_RELOAD_MARKER } from "./dev-reload-preload-probe";
import {
desktopIpc,
type CustomProviderConfig,
type CustomProviderProbeInput,
type CustomProviderProbeResult,
type DesktopNotificationPermissionStatus,
type PiDesktopCommand,
type TerminalDataEvent,
Expand Down Expand Up @@ -168,6 +171,14 @@ contextBridge.exposeInMainWorld("piApp", {
ipcRenderer.invoke(desktopIpc.logoutProvider, workspaceId, providerId) as Promise<DesktopAppState>,
setProviderApiKey: (workspaceId: string, providerId: string, apiKey: string) =>
ipcRenderer.invoke(desktopIpc.setProviderApiKey, workspaceId, providerId, apiKey) as Promise<DesktopAppState>,
listCustomProviders: () =>
ipcRenderer.invoke(desktopIpc.listCustomProviders) as Promise<readonly CustomProviderConfig[]>,
setCustomProvider: (workspaceId: string, config: CustomProviderConfig) =>
ipcRenderer.invoke(desktopIpc.setCustomProvider, workspaceId, config) as Promise<DesktopAppState>,
deleteCustomProvider: (workspaceId: string, providerId: string) =>
ipcRenderer.invoke(desktopIpc.deleteCustomProvider, workspaceId, providerId) as Promise<DesktopAppState>,
probeCustomProviderModels: (input: CustomProviderProbeInput) =>
ipcRenderer.invoke(desktopIpc.probeCustomProviderModels, input) as Promise<CustomProviderProbeResult>,
setEnableSkillCommands: (workspaceId: string, enabled: boolean) =>
ipcRenderer.invoke(desktopIpc.setEnableSkillCommands, workspaceId, enabled) as Promise<DesktopAppState>,
setScopedModelPatterns: (workspaceId: string, patterns: readonly string[]) =>
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
desktopCommands,
getDesktopCommandFromShortcut,
getDesktopShortcutLabel,
type CustomProviderConfig,
type DesktopNotificationPermissionStatus,
type PiDesktopCommand,
} from "./ipc";
Expand Down Expand Up @@ -1603,6 +1604,26 @@ export default function App() {
return state.lastError;
};

const handleSaveCustomProvider = async (config: CustomProviderConfig): Promise<string | undefined> => {
if (!api || !settingsWorkspace) {
return "Select a workspace first.";
}
const state = await updateSnapshot(api, setSnapshot, () =>
api.setCustomProvider(settingsWorkspace.id, config),
);
return state.lastError;
};

const handleDeleteCustomProvider = async (providerId: string): Promise<string | undefined> => {
if (!api || !settingsWorkspace) {
return "Select a workspace first.";
}
const state = await updateSnapshot(api, setSnapshot, () =>
api.deleteCustomProvider(settingsWorkspace.id, providerId),
);
return state.lastError;
};

const handleToggleSkill = (filePath: string, enabled: boolean) => {
if (!skillsWorkspace) {
return;
Expand Down Expand Up @@ -1906,6 +1927,8 @@ export default function App() {
onLogoutProvider={handleLogoutProvider}
onSetProviderApiKey={handleSetProviderApiKey}
onRemoveProviderApiKey={handleRemoveProviderApiKey}
onSaveCustomProvider={handleSaveCustomProvider}
onDeleteCustomProvider={handleDeleteCustomProvider}
onSetModelSettingsScopeMode={handleSetModelSettingsScopeMode}
onSetDefaultModel={handleSetDefaultModel}
onSetNotificationPreferences={handleSetNotificationPreferences}
Expand Down
29 changes: 29 additions & 0 deletions apps/desktop/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,27 @@ export type DesktopNotificationPermissionStatus =
| "unsupported"
| "unknown";

export interface CustomProviderModelConfig {
readonly id: string;
readonly contextWindow?: number;
}

export interface CustomProviderConfig {
readonly providerId: string;
readonly baseUrl: string;
readonly apiKey?: string;
readonly models: readonly CustomProviderModelConfig[];
}

export interface CustomProviderProbeInput {
readonly baseUrl: string;
readonly apiKey?: string;
}

export type CustomProviderProbeResult =
| { readonly ok: true; readonly models: readonly string[] }
| { readonly ok: false; readonly error: string };

export const desktopIpc = {
stateRequest: "pi-gui:state-request",
stateChanged: "pi-gui:state-changed",
Expand Down Expand Up @@ -63,6 +84,10 @@ export const desktopIpc = {
loginProvider: "pi-gui:login-provider",
logoutProvider: "pi-gui:logout-provider",
setProviderApiKey: "pi-gui:set-provider-api-key",
listCustomProviders: "pi-gui:list-custom-providers",
setCustomProvider: "pi-gui:set-custom-provider",
deleteCustomProvider: "pi-gui:delete-custom-provider",
probeCustomProviderModels: "pi-gui:probe-custom-provider-models",
setEnableSkillCommands: "pi-gui:set-enable-skill-commands",
setScopedModelPatterns: "pi-gui:set-scoped-model-patterns",
setSkillEnabled: "pi-gui:set-skill-enabled",
Expand Down Expand Up @@ -261,6 +286,10 @@ export interface PiDesktopApi {
loginProvider(workspaceId: string, providerId: string): Promise<DesktopAppState>;
logoutProvider(workspaceId: string, providerId: string): Promise<DesktopAppState>;
setProviderApiKey(workspaceId: string, providerId: string, apiKey: string): Promise<DesktopAppState>;
listCustomProviders(): Promise<readonly CustomProviderConfig[]>;
setCustomProvider(workspaceId: string, config: CustomProviderConfig): Promise<DesktopAppState>;
deleteCustomProvider(workspaceId: string, providerId: string): Promise<DesktopAppState>;
probeCustomProviderModels(input: CustomProviderProbeInput): Promise<CustomProviderProbeResult>;
setEnableSkillCommands(workspaceId: string, enabled: boolean): Promise<DesktopAppState>;
setScopedModelPatterns(workspaceId: string, patterns: readonly string[]): Promise<DesktopAppState>;
setSkillEnabled(workspaceId: string, filePath: string, enabled: boolean): Promise<DesktopAppState>;
Expand Down
Loading
Loading