allow creating and listing custom language model (#134)
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
199
src/components/CreateCustomModelDialog.tsx
Normal file
199
src/components/CreateCustomModelDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
src/components/settings/ApiKeyConfiguration.tsx
Normal file
194
src/components/settings/ApiKeyConfiguration.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/components/settings/ModelsSection.tsx
Normal file
114
src/components/settings/ModelsSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/components/settings/ProviderSettingsHeader.tsx
Normal file
115
src/components/settings/ProviderSettingsHeader.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
29
src/hooks/useLanguageModelsForProvider.ts
Normal file
29
src/hooks/useLanguageModelsForProvider.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ---
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user