allow creating and listing custom language model (#134)

This commit is contained in:
Will Chen
2025-05-12 16:00:16 -07:00
committed by GitHub
parent c63781d7cc
commit 477015b43d
13 changed files with 925 additions and 291 deletions

View File

@@ -7,7 +7,8 @@ You're building an Electron app following good security practices
# IPC # IPC
Structure: Structure:
- [ipc_client.ts](mdc:src/ipc/ipc_client.ts) - lives in the renderer process and is used to send IPCs to the main process - [ipc_client.ts](mdc:src/ipc/ipc_client.ts) - lives in the renderer process and is used to send IPCs to the main process.
- to use it just do `IpcClient.getInstance()`
- [preload.ts](mdc:src/preload.ts) - allowlist - [preload.ts](mdc:src/preload.ts) - allowlist
- [ipc_host.ts](mdc:src/ipc/ipc_host.ts) - contains the various IPC handlers attached which are: [app_handlers.ts](mdc:src/ipc/handlers/app_handlers.ts), [chat_stream_handlers.ts](mdc:src/ipc/handlers/chat_stream_handlers.ts), [settings_handlers.ts](mdc:src/ipc/handlers/settings_handlers.ts) etc. - [ipc_host.ts](mdc:src/ipc/ipc_host.ts) - contains the various IPC handlers attached which are: [app_handlers.ts](mdc:src/ipc/handlers/app_handlers.ts), [chat_stream_handlers.ts](mdc:src/ipc/handlers/chat_stream_handlers.ts), [settings_handlers.ts](mdc:src/ipc/handlers/settings_handlers.ts) etc.

View File

@@ -0,0 +1,199 @@
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { IpcClient } from "@/ipc/ipc_client";
import { useMutation } from "@tanstack/react-query";
import { showError, showSuccess } from "@/lib/toast";
interface CreateCustomModelDialogProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
providerId: string;
}
export function CreateCustomModelDialog({
isOpen,
onClose,
onSuccess,
providerId,
}: CreateCustomModelDialogProps) {
const [id, setId] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [maxOutputTokens, setMaxOutputTokens] = useState<string>("");
const [contextWindow, setContextWindow] = useState<string>("");
const ipcClient = IpcClient.getInstance();
const mutation = useMutation({
mutationFn: async () => {
const params = {
id,
name,
providerId,
description: description || undefined,
maxOutputTokens: maxOutputTokens
? parseInt(maxOutputTokens, 10)
: undefined,
contextWindow: contextWindow ? parseInt(contextWindow, 10) : undefined,
};
if (!params.id) throw new Error("Model ID is required");
if (!params.name) throw new Error("Model Name is required");
if (maxOutputTokens && isNaN(params.maxOutputTokens ?? NaN))
throw new Error("Max Output Tokens must be a valid number");
if (contextWindow && isNaN(params.contextWindow ?? NaN))
throw new Error("Context Window must be a valid number");
await ipcClient.createCustomLanguageModel(params);
},
onSuccess: () => {
showSuccess("Custom model created successfully!");
resetForm();
onSuccess(); // Refetch or update UI
onClose();
},
onError: (error) => {
showError(error);
},
});
const resetForm = () => {
setId("");
setName("");
setDescription("");
setMaxOutputTokens("");
setContextWindow("");
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate();
};
const handleClose = () => {
if (!mutation.isPending) {
resetForm();
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Add Custom Model</DialogTitle>
<DialogDescription>
Configure a new language model for the selected provider.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="model-id" className="text-right">
Model ID*
</Label>
<Input
id="model-id"
value={id}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setId(e.target.value)
}
className="col-span-3"
placeholder="This must match the model expected by the API"
required
disabled={mutation.isPending}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="model-name" className="text-right">
Name*
</Label>
<Input
id="model-name"
value={name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setName(e.target.value)
}
className="col-span-3"
placeholder="Human-friendly name for the model"
required
disabled={mutation.isPending}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
Description
</Label>
<Input
id="description"
value={description}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setDescription(e.target.value)
}
className="col-span-3"
placeholder="Optional: Describe the model's capabilities"
disabled={mutation.isPending}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="max-output-tokens" className="text-right">
Max Output Tokens
</Label>
<Input
id="max-output-tokens"
type="number"
value={maxOutputTokens}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMaxOutputTokens(e.target.value)
}
className="col-span-3"
placeholder="Optional: e.g., 4096"
disabled={mutation.isPending}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="context-window" className="text-right">
Context Window
</Label>
<Input
id="context-window"
type="number"
value={contextWindow}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setContextWindow(e.target.value)
}
className="col-span-3"
placeholder="Optional: e.g., 8192"
disabled={mutation.isPending}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Adding..." : "Add Model"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,194 @@
import { Info, KeyRound, Trash2 } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { UserSettings } from "@/lib/schemas";
// Helper function to mask ENV API keys (move or duplicate if needed elsewhere)
const maskEnvApiKey = (key: string | undefined): string => {
if (!key) return "Not Set";
if (key.length < 8) return "****";
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
};
interface ApiKeyConfigurationProps {
provider: string;
providerDisplayName: string;
settings: UserSettings | null | undefined;
envVars: Record<string, string | undefined>;
envVarName?: string;
isSaving: boolean;
saveError: string | null;
apiKeyInput: string;
onApiKeyInputChange: (value: string) => void;
onSaveKey: () => Promise<void>;
onDeleteKey: () => Promise<void>;
isDyad: boolean;
}
export function ApiKeyConfiguration({
provider,
providerDisplayName,
settings,
envVars,
envVarName,
isSaving,
saveError,
apiKeyInput,
onApiKeyInputChange,
onSaveKey,
onDeleteKey,
isDyad,
}: ApiKeyConfigurationProps) {
const envApiKey = envVarName ? envVars[envVarName] : undefined;
const userApiKey = settings?.providerSettings?.[provider]?.apiKey?.value;
const isValidUserKey =
!!userApiKey &&
!userApiKey.startsWith("Invalid Key") &&
userApiKey !== "Not Set";
const hasEnvKey = !!envApiKey;
const activeKeySource = isValidUserKey
? "settings"
: hasEnvKey
? "env"
: "none";
const defaultAccordionValue = [];
if (isValidUserKey || !hasEnvKey) {
defaultAccordionValue.push("settings-key");
}
if (!isDyad && hasEnvKey) {
defaultAccordionValue.push("env-key");
}
return (
<Accordion
type="multiple"
className="w-full space-y-4"
defaultValue={defaultAccordionValue}
>
<AccordionItem
value="settings-key"
className="border rounded-lg px-4 bg-(--background-lightest)"
>
<AccordionTrigger className="text-lg font-medium hover:no-underline cursor-pointer">
API Key from Settings
</AccordionTrigger>
<AccordionContent className="pt-4 ">
{isValidUserKey && (
<Alert variant="default" className="mb-4">
<KeyRound className="h-4 w-4" />
<AlertTitle className="flex justify-between items-center">
<span>Current Key (Settings)</span>
<Button
variant="destructive"
size="sm"
onClick={onDeleteKey}
disabled={isSaving}
className="flex items-center gap-1 h-7 px-2"
>
<Trash2 className="h-4 w-4" />
{isSaving ? "Deleting..." : "Delete"}
</Button>
</AlertTitle>
<AlertDescription>
<p className="font-mono text-sm">{userApiKey}</p>
{activeKeySource === "settings" && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
This key is currently active.
</p>
)}
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<label
htmlFor="apiKeyInput"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{isValidUserKey ? "Update" : "Set"} {providerDisplayName} API Key
</label>
<div className="flex items-start space-x-2">
<Input
id="apiKeyInput"
value={apiKeyInput}
onChange={(e) => onApiKeyInputChange(e.target.value)}
placeholder={`Enter new ${providerDisplayName} API Key here`}
className={`flex-grow ${saveError ? "border-red-500" : ""}`}
/>
<Button onClick={onSaveKey} disabled={isSaving || !apiKeyInput}>
{isSaving ? "Saving..." : "Save Key"}
</Button>
</div>
{saveError && <p className="text-xs text-red-600">{saveError}</p>}
<p className="text-xs text-gray-500 dark:text-gray-400">
Setting a key here will override the environment variable (if
set).
</p>
</div>
</AccordionContent>
</AccordionItem>
{!isDyad && envVarName && (
<AccordionItem
value="env-key"
className="border rounded-lg px-4 bg-(--background-lightest)"
>
<AccordionTrigger className="text-lg font-medium hover:no-underline cursor-pointer">
API Key from Environment Variable
</AccordionTrigger>
<AccordionContent className="pt-4">
{hasEnvKey ? (
<Alert variant="default">
<KeyRound className="h-4 w-4" />
<AlertTitle>Environment Variable Key ({envVarName})</AlertTitle>
<AlertDescription>
<p className="font-mono text-sm">
{maskEnvApiKey(envApiKey)}
</p>
{activeKeySource === "env" && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
This key is currently active (no settings key set).
</p>
)}
{activeKeySource === "settings" && (
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
This key is currently being overridden by the key set in
Settings.
</p>
)}
</AlertDescription>
</Alert>
) : (
<Alert variant="default">
<Info className="h-4 w-4" />
<AlertTitle>Environment Variable Not Set</AlertTitle>
<AlertDescription>
The{" "}
<code className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">
{envVarName}
</code>{" "}
environment variable is not set.
</AlertDescription>
</Alert>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
This key is set outside the application. If present, it will be
used only if no key is configured in the Settings section above.
Requires app restart to detect changes.
</p>
</AccordionContent>
</AccordionItem>
)}
</Accordion>
);
}

View File

@@ -0,0 +1,114 @@
import { useState } from "react";
import { AlertTriangle, PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { CreateCustomModelDialog } from "@/components/CreateCustomModelDialog";
import { useLanguageModelsForProvider } from "@/hooks/useLanguageModelsForProvider"; // Use the hook directly here
interface ModelsSectionProps {
providerId: string;
}
export function ModelsSection({ providerId }: ModelsSectionProps) {
const [isCustomModelDialogOpen, setIsCustomModelDialogOpen] = useState(false);
// Fetch custom models within this component now
const {
data: models,
isLoading: modelsLoading,
error: modelsError,
refetch: refetchModels,
} = useLanguageModelsForProvider(providerId);
return (
<div className="mt-8 border-t pt-6">
<h2 className="text-2xl font-semibold mb-4">Models</h2>
<p className="text-muted-foreground mb-4">
Manage specific models available through this provider.
</p>
{/* Custom Models List Area */}
{modelsLoading && (
<div className="space-y-3 mt-4">
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
)}
{modelsError && (
<Alert variant="destructive" className="mt-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error Loading Models</AlertTitle>
<AlertDescription>{modelsError.message}</AlertDescription>
</Alert>
)}
{!modelsLoading && !modelsError && models && models.length > 0 && (
<div className="mt-4 space-y-3">
{models.map((model) => (
<div
key={model.name}
className="p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm"
>
<div className="flex justify-between items-center">
<h4 className="text-lg font-semibold text-gray-800 dark:text-gray-100">
{model.displayName}
</h4>
{/* Optional: Add an edit/delete button here later */}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
{model.name}
</p>
{model.description && (
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">
{model.description}
</p>
)}
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-xs text-gray-500 dark:text-gray-400">
{model.contextWindow && (
<span>
Context: {model.contextWindow.toLocaleString()} tokens
</span>
)}
{model.maxOutputTokens && (
<span>
Max Output: {model.maxOutputTokens.toLocaleString()} tokens
</span>
)}
</div>
{model.tag && (
<span className="mt-2 inline-block bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
{model.tag}
</span>
)}
</div>
))}
</div>
)}
{!modelsLoading && !modelsError && (!models || models.length === 0) && (
<p className="text-muted-foreground mt-4">
No custom models have been added for this provider yet.
</p>
)}
{/* End Custom Models List Area */}
<Button
onClick={() => setIsCustomModelDialogOpen(true)}
variant="outline"
className="mt-6"
>
<PlusIcon className="mr-2 h-4 w-4" /> Add Custom Model
</Button>
{/* Render the dialog */}
<CreateCustomModelDialog
isOpen={isCustomModelDialogOpen}
onClose={() => setIsCustomModelDialogOpen(false)}
onSuccess={() => {
setIsCustomModelDialogOpen(false);
refetchModels(); // Refetch models on success
}}
providerId={providerId}
/>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import {
ArrowLeft,
Circle,
ExternalLink,
GiftIcon,
KeyRound,
Settings as SettingsIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { IpcClient } from "@/ipc/ipc_client";
interface ProviderSettingsHeaderProps {
providerDisplayName: string;
isConfigured: boolean;
isLoading: boolean;
hasFreeTier?: boolean;
providerWebsiteUrl?: string;
isDyad: boolean;
onBackClick: () => void;
}
function getKeyButtonText({
isConfigured,
isDyad,
}: {
isConfigured: boolean;
isDyad: boolean;
}) {
if (isDyad) {
return isConfigured
? "Manage Dyad Pro Subscription"
: "Setup Dyad Pro Subscription";
}
return isConfigured ? "Manage API Keys" : "Setup API Key";
}
export function ProviderSettingsHeader({
providerDisplayName,
isConfigured,
isLoading,
hasFreeTier,
providerWebsiteUrl,
isDyad,
onBackClick,
}: ProviderSettingsHeaderProps) {
const handleGetApiKeyClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (providerWebsiteUrl) {
IpcClient.getInstance().openExternalUrl(providerWebsiteUrl);
}
};
return (
<>
<Button
onClick={onBackClick}
variant="outline"
size="sm"
className="flex items-center gap-2 mb-4 bg-(--background-lightest) py-5"
>
<ArrowLeft className="h-4 w-4" />
Go Back
</Button>
<div className="mb-6">
<div className="flex items-center mb-1">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mr-3">
Configure {providerDisplayName}
</h1>
{isLoading ? (
<Skeleton className="h-6 w-6 rounded-full" />
) : (
<Circle
className={`h-5 w-5 ${
isConfigured
? "fill-green-500 text-green-600"
: "fill-yellow-400 text-yellow-500"
}`}
/>
)}
<span className="ml-2 text-sm text-gray-600 dark:text-gray-400">
{isLoading
? "Loading..."
: isConfigured
? "Setup Complete"
: "Not Setup"}
</span>
</div>
{!isLoading && hasFreeTier && (
<span className="text-blue-600 mt-2 dark:text-blue-400 text-sm font-medium bg-blue-100 dark:bg-blue-900/30 px-2 py-1 rounded-full inline-flex items-center">
<GiftIcon className="w-4 h-4 mr-1" />
Free tier available
</span>
)}
</div>
{providerWebsiteUrl && !isLoading && (
<Button
onClick={handleGetApiKeyClick}
className="mb-4 bg-(--background-lightest) cursor-pointer py-5"
variant="outline"
>
{isConfigured ? (
<SettingsIcon className="mr-2 h-4 w-4" />
) : (
<KeyRound className="mr-2 h-4 w-4" />
)}
{getKeyButtonText({ isConfigured, isDyad })}
<ExternalLink className="ml-2 h-4 w-4" />
</Button>
)}
</>
);
}

View File

@@ -1,44 +1,26 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router";
import { import { ArrowLeft, AlertTriangle } from "lucide-react";
ArrowLeft,
ExternalLink,
KeyRound,
Info,
Circle,
Settings as SettingsIcon,
GiftIcon,
Trash2,
AlertTriangle,
} from "lucide-react";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders"; import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import {} from "@/components/ui/accordion";
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { IpcClient } from "@/ipc/ipc_client";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
import { UserSettings } from "@/lib/schemas"; import { UserSettings } from "@/lib/schemas";
import { ProviderSettingsHeader } from "./ProviderSettingsHeader";
import { ApiKeyConfiguration } from "./ApiKeyConfiguration";
import { ModelsSection } from "./ModelsSection";
interface ProviderSettingsPageProps { interface ProviderSettingsPageProps {
provider: string; provider: string;
} }
// Helper function to mask ENV API keys (still needed for env vars)
const maskEnvApiKey = (key: string | undefined): string => {
if (!key) return "Not Set";
if (key.length < 8) return "****";
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
};
export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) { export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
const { const {
settings, settings,
@@ -55,6 +37,11 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
error: providersError, error: providersError,
} = useLanguageModelProviders(); } = useLanguageModelProviders();
// Find the specific provider data from the fetched list
const providerData = allProviders?.find((p) => p.id === provider);
const supportsCustomModels =
providerData?.type === "custom" || providerData?.type === "cloud";
const isDyad = provider === "auto"; const isDyad = provider === "auto";
const [apiKeyInput, setApiKeyInput] = useState(""); const [apiKeyInput, setApiKeyInput] = useState("");
@@ -62,9 +49,6 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
// Find the specific provider data from the fetched list
const providerData = allProviders?.find((p) => p.id === provider);
// Use fetched data (or defaults for Dyad) // Use fetched data (or defaults for Dyad)
const providerDisplayName = isDyad const providerDisplayName = isDyad
? "Dyad" ? "Dyad"
@@ -74,7 +58,6 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
: providerData?.websiteUrl; : providerData?.websiteUrl;
const hasFreeTier = isDyad ? false : providerData?.hasFreeTier; const hasFreeTier = isDyad ? false : providerData?.hasFreeTier;
const envVarName = isDyad ? undefined : providerData?.envVarName; const envVarName = isDyad ? undefined : providerData?.envVarName;
const envApiKey = envVarName ? envVars[envVarName] : undefined;
// Use provider ID (which is the 'provider' prop) // Use provider ID (which is the 'provider' prop)
const userApiKey = settings?.providerSettings?.[provider]?.apiKey?.value; const userApiKey = settings?.providerSettings?.[provider]?.apiKey?.value;
@@ -84,25 +67,9 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
!!userApiKey && !!userApiKey &&
!userApiKey.startsWith("Invalid Key") && !userApiKey.startsWith("Invalid Key") &&
userApiKey !== "Not Set"; userApiKey !== "Not Set";
const hasEnvKey = !!envApiKey; const hasEnvKey = !!(envVarName && envVars[envVarName]);
const isConfigured = isValidUserKey || hasEnvKey; // Configured if either is set const isConfigured = isValidUserKey || hasEnvKey; // Configured if either is set
// Settings key takes precedence if it's valid
const activeKeySource = isValidUserKey
? "settings"
: hasEnvKey
? "env"
: "none";
// --- Accordion Logic ---
const defaultAccordionValue = [];
if (isValidUserKey || !hasEnvKey) {
// If user key is set OR env key is NOT set, open the settings accordion item
defaultAccordionValue.push("settings-key");
}
if (hasEnvKey) {
defaultAccordionValue.push("env-key");
}
// --- Save Handler --- // --- Save Handler ---
const handleSaveKey = async () => { const handleSaveKey = async () => {
@@ -182,24 +149,23 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
} }
}, [apiKeyInput]); }, [apiKeyInput]);
// --- Loading State for Providers --- (Added) // --- Loading State for Providers ---
if (providersLoading) { if (providersLoading) {
return ( return (
<div className="min-h-screen px-8 py-4"> <div className="min-h-screen px-8 py-4">
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<Skeleton className="h-8 w-24 mb-4" /> {/* Back button */} <Skeleton className="h-8 w-24 mb-4" />
<Skeleton className="h-10 w-1/2 mb-6" /> {/* Title */} <Skeleton className="h-10 w-1/2 mb-6" />
<Skeleton className="h-10 w-48 mb-4" /> {/* Get Key button */} <Skeleton className="h-10 w-48 mb-4" />
<div className="space-y-4"> <div className="space-y-4 mt-6">
<Skeleton className="h-20 w-full" /> <Skeleton className="h-40 w-full" />
<Skeleton className="h-20 w-full" />
</div> </div>
</div> </div>
</div> </div>
); );
} }
// --- Error State for Providers --- (Added) // --- Error State for Providers ---
if (providersError) { if (providersError) {
return ( return (
<div className="min-h-screen px-8 py-4"> <div className="min-h-screen px-8 py-4">
@@ -260,71 +226,19 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
return ( return (
<div className="min-h-screen px-8 py-4"> <div className="min-h-screen px-8 py-4">
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<Button <ProviderSettingsHeader
onClick={() => router.history.back()} providerDisplayName={providerDisplayName}
variant="outline" isConfigured={isConfigured}
size="sm" isLoading={settingsLoading}
className="flex items-center gap-2 mb-4 bg-(--background-lightest) py-5" hasFreeTier={hasFreeTier}
> providerWebsiteUrl={providerWebsiteUrl}
<ArrowLeft className="h-4 w-4" /> isDyad={isDyad}
Go Back onBackClick={() => router.history.back()}
</Button>
<div className="mb-6">
<div className="flex items-center mb-1">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mr-3">
Configure {providerDisplayName}
</h1>
{settingsLoading ? (
<Skeleton className="h-6 w-6 rounded-full" />
) : (
<Circle
className={`h-5 w-5 ${
isConfigured
? "fill-green-500 text-green-600"
: "fill-yellow-400 text-yellow-500"
}`}
/> />
)}
<span className="ml-2 text-sm text-gray-600 dark:text-gray-400">
{settingsLoading
? "Loading..."
: isConfigured
? "Setup Complete"
: "Not Setup"}
</span>
</div>
{!settingsLoading && hasFreeTier && (
<span className="text-blue-600 mt-2 dark:text-blue-400 text-sm font-medium bg-blue-100 dark:bg-blue-900/30 px-2 py-1 rounded-full inline-flex items-center">
<GiftIcon className="w-4 h-4 mr-1" />
Free tier available
</span>
)}
</div>
{providerWebsiteUrl && !settingsLoading && (
<Button
onClick={(e) => {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(providerWebsiteUrl);
}}
className="mb-4 bg-(--background-lightest) cursor-pointer py-5"
variant="outline"
>
{isConfigured ? (
<SettingsIcon className="mr-2 h-4 w-4" />
) : (
<KeyRound className="mr-2 h-4 w-4" />
)}
{getKeyButtonText({ isConfigured, isDyad })}
<ExternalLink className="ml-2 h-4 w-4" />
</Button>
)}
{settingsLoading ? ( {settingsLoading ? (
<div className="space-y-4"> <div className="space-y-4">
<Skeleton className="h-20 w-full" /> <Skeleton className="h-40 w-full" />
<Skeleton className="h-20 w-full" />
</div> </div>
) : settingsError ? ( ) : settingsError ? (
<Alert variant="destructive"> <Alert variant="destructive">
@@ -334,136 +248,20 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : ( ) : (
<Accordion <ApiKeyConfiguration
type="multiple" provider={provider}
className="w-full space-y-4" providerDisplayName={providerDisplayName}
defaultValue={defaultAccordionValue} settings={settings}
> envVars={envVars}
<AccordionItem envVarName={envVarName}
value="settings-key" isSaving={isSaving}
className="border rounded-lg px-4 bg-(--background-lightest)" saveError={saveError}
> apiKeyInput={apiKeyInput}
<AccordionTrigger className="text-lg font-medium hover:no-underline cursor-pointer"> onApiKeyInputChange={setApiKeyInput}
API Key from Settings onSaveKey={handleSaveKey}
</AccordionTrigger> onDeleteKey={handleDeleteKey}
<AccordionContent className="pt-4 "> isDyad={isDyad}
{isValidUserKey && (
<Alert variant="default" className="mb-4">
<KeyRound className="h-4 w-4" />
<AlertTitle className="flex justify-between items-center">
<span>Current Key (Settings)</span>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteKey}
disabled={isSaving}
className="flex items-center gap-1 h-7 px-2"
>
<Trash2 className="h-4 w-4" />
{isSaving ? "Deleting..." : "Delete"}
</Button>
</AlertTitle>
<AlertDescription>
<p className="font-mono text-sm">{userApiKey}</p>
{activeKeySource === "settings" && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
This key is currently active.
</p>
)}
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<label
htmlFor="apiKeyInput"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{isValidUserKey ? "Update" : "Set"} {providerDisplayName}{" "}
API Key
</label>
<div className="flex items-start space-x-2">
<Input
id="apiKeyInput"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder={`Enter new ${providerDisplayName} API Key here`}
className={`flex-grow ${
saveError ? "border-red-500" : ""
}`}
/> />
<Button
onClick={handleSaveKey}
disabled={isSaving || !apiKeyInput}
>
{isSaving ? "Saving..." : "Save Key"}
</Button>
</div>
{saveError && (
<p className="text-xs text-red-600">{saveError}</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
Setting a key here will override the environment variable
(if set).
</p>
</div>
</AccordionContent>
</AccordionItem>
{!isDyad && envVarName && (
<AccordionItem
value="env-key"
className="border rounded-lg px-4 bg-(--background-lightest)"
>
<AccordionTrigger className="text-lg font-medium hover:no-underline cursor-pointer">
API Key from Environment Variable
</AccordionTrigger>
<AccordionContent className="pt-4">
{hasEnvKey ? (
<Alert variant="default">
<KeyRound className="h-4 w-4" />
<AlertTitle>
Environment Variable Key ({envVarName})
</AlertTitle>
<AlertDescription>
<p className="font-mono text-sm">
{maskEnvApiKey(envApiKey)}
</p>
{activeKeySource === "env" && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
This key is currently active (no settings key set).
</p>
)}
{activeKeySource === "settings" && (
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
This key is currently being overridden by the key
set in Settings.
</p>
)}
</AlertDescription>
</Alert>
) : (
<Alert variant="default">
<Info className="h-4 w-4" />
<AlertTitle>Environment Variable Not Set</AlertTitle>
<AlertDescription>
The{" "}
<code className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">
{envVarName}
</code>{" "}
environment variable is not set.
</AlertDescription>
</Alert>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
This key is set outside the application. If present, it will
be used only if no key is configured in the Settings section
above. Requires app restart to detect changes.
</p>
</AccordionContent>
</AccordionItem>
)}
</Accordion>
)} )}
{isDyad && !settingsLoading && ( {isDyad && !settingsLoading && (
@@ -481,22 +279,13 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
/> />
</div> </div>
)} )}
{/* Conditionally render CustomModelsSection */}
{supportsCustomModels && providerData && (
<ModelsSection providerId={providerData.id} />
)}
<div className="h-24"></div>
</div> </div>
</div> </div>
); );
} }
function getKeyButtonText({
isConfigured,
isDyad,
}: {
isConfigured: boolean;
isDyad: boolean;
}) {
if (isDyad) {
return isConfigured
? "Manage Dyad Pro Subscription"
: "Setup Dyad Pro Subscription";
}
return isConfigured ? "Manage API Keys" : "Setup API Key";
}

View File

@@ -1,15 +1,11 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import type { LanguageModelProvider } from "@/ipc/ipc_types"; import type {
CreateCustomLanguageModelProviderParams,
LanguageModelProvider,
} from "@/ipc/ipc_types";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
export interface CreateCustomLanguageModelProviderParams {
id: string;
name: string;
apiBaseUrl: string;
envVarName?: string;
}
export function useCustomLanguageModelProvider() { export function useCustomLanguageModelProvider() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const ipcClient = IpcClient.getInstance(); const ipcClient = IpcClient.getInstance();

View File

@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import type { LanguageModel } from "@/ipc/ipc_types";
/**
* Fetches the list of available language models for a specific provider.
*
* @param providerId The ID of the language model provider.
* @returns TanStack Query result object for the language models.
*/
export function useLanguageModelsForProvider(providerId: string | undefined) {
const ipcClient = IpcClient.getInstance();
return useQuery<
LanguageModel[],
Error // Specify Error type for better error handling
>({
queryKey: ["language-models", providerId],
queryFn: async () => {
if (!providerId) {
// Avoid calling IPC if providerId is not set
// Return an empty array as it's a query, not an error state
return [];
}
return ipcClient.getLanguageModels({ providerId });
},
enabled: !!providerId,
});
}

View File

@@ -1,22 +1,26 @@
import type { LanguageModelProvider } from "@/ipc/ipc_types"; import type {
LanguageModelProvider,
LanguageModel,
CreateCustomLanguageModelProviderParams,
CreateCustomLanguageModelParams,
} from "@/ipc/ipc_types";
import { createLoggedHandler } from "./safe_handle"; import { createLoggedHandler } from "./safe_handle";
import log from "electron-log"; import log from "electron-log";
import { getLanguageModelProviders } from "../shared/language_model_helpers"; import {
getLanguageModelProviders,
getLanguageModels,
} from "../shared/language_model_helpers";
import { db } from "@/db"; import { db } from "@/db";
import { language_model_providers as languageModelProvidersSchema } from "@/db/schema"; import {
language_model_providers as languageModelProvidersSchema,
language_models as languageModelsSchema,
} from "@/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { IpcMainInvokeEvent } from "electron"; import { IpcMainInvokeEvent } from "electron";
const logger = log.scope("language_model_handlers"); const logger = log.scope("language_model_handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
export interface CreateCustomLanguageModelProviderParams {
id: string;
name: string;
apiBaseUrl: string;
envVarName?: string;
}
export function registerLanguageModelHandlers() { export function registerLanguageModelHandlers() {
handle( handle(
"get-language-model-providers", "get-language-model-providers",
@@ -47,7 +51,7 @@ export function registerLanguageModelHandlers() {
} }
// Check if a provider with this ID already exists // Check if a provider with this ID already exists
const existingProvider = await db const existingProvider = db
.select() .select()
.from(languageModelProvidersSchema) .from(languageModelProvidersSchema)
.where(eq(languageModelProvidersSchema.id, id)) .where(eq(languageModelProvidersSchema.id, id))
@@ -75,4 +79,77 @@ export function registerLanguageModelHandlers() {
}; };
}, },
); );
handle(
"create-custom-language-model",
async (
event: IpcMainInvokeEvent,
params: CreateCustomLanguageModelParams,
): Promise<void> => {
const {
id,
name,
providerId,
description,
maxOutputTokens,
contextWindow,
} = params;
// Validation
if (!id) {
throw new Error("Model ID is required");
}
if (!name) {
throw new Error("Model name is required");
}
if (!providerId) {
throw new Error("Provider ID is required");
}
// Check if provider exists
const provider = db
.select()
.from(languageModelProvidersSchema)
.where(eq(languageModelProvidersSchema.id, providerId))
.get();
if (!provider) {
throw new Error(`Provider with ID "${providerId}" not found`);
}
// Check if model ID already exists
const existingModel = db
.select()
.from(languageModelsSchema)
.where(eq(languageModelsSchema.id, id))
.get();
if (existingModel) {
throw new Error(`A model with ID "${id}" already exists`);
}
// Insert the new model
await db.insert(languageModelsSchema).values({
id,
name,
provider_id: providerId,
description: description || null,
max_output_tokens: maxOutputTokens || null,
context_window: contextWindow || null,
});
},
);
handle(
"get-language-models",
async (
event: IpcMainInvokeEvent,
params: { providerId: string },
): Promise<LanguageModel[]> => {
if (!params || typeof params.providerId !== "string") {
throw new Error("Invalid parameters: providerId (string) is required.");
}
return getLanguageModels({ providerId: params.providerId });
},
);
} }

View File

@@ -22,6 +22,9 @@ import type {
ChatLogsData, ChatLogsData,
BranchResult, BranchResult,
LanguageModelProvider, LanguageModelProvider,
LanguageModel,
CreateCustomLanguageModelProviderParams,
CreateCustomLanguageModelParams,
} from "./ipc_types"; } from "./ipc_types";
import type { ProposalResult } from "@/lib/schemas"; import type { ProposalResult } from "@/lib/schemas";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
@@ -732,17 +735,18 @@ export class IpcClient {
return this.ipcRenderer.invoke("get-language-model-providers"); return this.ipcRenderer.invoke("get-language-model-providers");
} }
public async getLanguageModels(params: {
providerId: string;
}): Promise<LanguageModel[]> {
return this.ipcRenderer.invoke("get-language-models", params);
}
public async createCustomLanguageModelProvider({ public async createCustomLanguageModelProvider({
id, id,
name, name,
apiBaseUrl, apiBaseUrl,
envVarName, envVarName,
}: { }: CreateCustomLanguageModelProviderParams): Promise<LanguageModelProvider> {
id: string;
name: string;
apiBaseUrl: string;
envVarName?: string;
}): Promise<LanguageModelProvider> {
return this.ipcRenderer.invoke("create-custom-language-model-provider", { return this.ipcRenderer.invoke("create-custom-language-model-provider", {
id, id,
name, name,
@@ -751,5 +755,11 @@ export class IpcClient {
}); });
} }
public async createCustomLanguageModel(
params: CreateCustomLanguageModelParams,
): Promise<void> {
await this.ipcRenderer.invoke("create-custom-language-model", params);
}
// --- End window control methods --- // --- End window control methods ---
} }

View File

@@ -143,3 +143,30 @@ export interface LanguageModelProvider {
apiBaseUrl?: string; apiBaseUrl?: string;
type: "custom" | "local" | "cloud"; type: "custom" | "local" | "cloud";
} }
export interface LanguageModel {
id: string;
name: string;
displayName: string;
description: string;
tag?: string;
maxOutputTokens?: number;
contextWindow?: number;
type: "local" | "cloud" | "custom";
}
export interface CreateCustomLanguageModelProviderParams {
id: string;
name: string;
apiBaseUrl: string;
envVarName?: string;
}
export interface CreateCustomLanguageModelParams {
id: string;
name: string;
providerId: string;
description?: string;
maxOutputTokens?: number;
contextWindow?: number;
}

View File

@@ -1,7 +1,11 @@
import { db } from "@/db"; import { db } from "@/db";
import { language_model_providers as languageModelProvidersSchema } from "@/db/schema"; import {
import { RegularModelProvider } from "@/constants/models"; language_model_providers as languageModelProvidersSchema,
import type { LanguageModelProvider } from "@/ipc/ipc_types"; language_models as languageModelsSchema,
} from "@/db/schema";
import { MODEL_OPTIONS, RegularModelProvider } from "@/constants/models";
import type { LanguageModelProvider, LanguageModel } from "@/ipc/ipc_types";
import { eq } from "drizzle-orm";
export const PROVIDER_TO_ENV_VAR: Record<string, string> = { export const PROVIDER_TO_ENV_VAR: Record<string, string> = {
openai: "OPENAI_API_KEY", openai: "OPENAI_API_KEY",
@@ -129,3 +133,80 @@ export async function getLanguageModelProviders(): Promise<
return Array.from(mergedProvidersMap.values()); return Array.from(mergedProvidersMap.values());
} }
/**
* Fetches language models for a specific provider.
* @param obj An object containing the providerId.
* @returns A promise that resolves to an array of LanguageModel objects.
*/
export async function getLanguageModels(obj: {
providerId: string;
}): Promise<LanguageModel[]> {
const { providerId } = obj;
const allProviders = await getLanguageModelProviders();
const provider = allProviders.find((p) => p.id === providerId);
if (!provider) {
console.warn(`Provider with ID "${providerId}" not found.`);
return [];
}
if (provider.type === "cloud") {
// Check if providerId is a valid key for MODEL_OPTIONS
if (providerId in MODEL_OPTIONS) {
const models = MODEL_OPTIONS[providerId as RegularModelProvider] || [];
return models.map((model) => ({
...model,
id: model.name,
type: "cloud",
}));
} else {
console.warn(
`Provider "${providerId}" is cloud type but not found in MODEL_OPTIONS.`,
);
return [];
}
} else if (provider.type === "custom") {
// Fetch models from the database for this custom provider
// Assuming a language_models table with necessary columns and provider_id foreign key
try {
const customModelsDb = await db
.select({
id: languageModelsSchema.id,
// Map DB columns to LanguageModel fields
name: languageModelsSchema.name,
// No display_name in DB, use name instead
description: languageModelsSchema.description,
// No tag in DB
maxOutputTokens: languageModelsSchema.max_output_tokens,
contextWindow: languageModelsSchema.context_window,
})
.from(languageModelsSchema)
.where(eq(languageModelsSchema.provider_id, providerId)); // Assuming eq is imported or available
return customModelsDb.map((model) => ({
...model,
displayName: model.name, // Use name as displayName for custom models
// Ensure possibly null fields are handled, provide defaults or undefined if needed
description: model.description ?? "",
tag: undefined, // No tag for custom models from DB
maxOutputTokens: model.maxOutputTokens ?? undefined,
contextWindow: model.contextWindow ?? undefined,
type: "custom",
}));
} catch (error) {
console.error(
`Error fetching custom models for provider "${providerId}" from DB:`,
error,
);
// Depending on desired behavior, could throw, return empty, or return a specific error state
return [];
}
} else {
// Handle other types like "local" if necessary, currently ignored
console.warn(
`Provider type "${provider.type}" not handled for model fetching.`,
);
return [];
}
}

View File

@@ -5,6 +5,8 @@ import { contextBridge, ipcRenderer } from "electron";
// Whitelist of valid channels // Whitelist of valid channels
const validInvokeChannels = [ const validInvokeChannels = [
"get-language-models",
"create-custom-language-model",
"get-language-model-providers", "get-language-model-providers",
"create-custom-language-model-provider", "create-custom-language-model-provider",
"chat:add-dep", "chat:add-dep",