Prep for custom models: support reading custom providers (#131)

This commit is contained in:
Will Chen
2025-05-12 14:52:48 -07:00
committed by GitHub
parent 79a2b5a906
commit cd7eaa8ece
23 changed files with 901 additions and 173 deletions

View File

@@ -66,27 +66,7 @@ The pattern involves a client-side React hook interacting with main process IPC
* Contains the core business logic, interacting with databases (e.g., `db`), file system (`fs`), or other main-process services (e.g., `git`). * Contains the core business logic, interacting with databases (e.g., `db`), file system (`fs`), or other main-process services (e.g., `git`).
* **Error Handling (Crucial):** * **Error Handling (Crucial):**
* **Handlers MUST `throw new Error("Descriptive error message")` when an operation fails or an invalid state is encountered.** This is the preferred pattern over returning objects like `{ success: false, errorMessage: "..." }`. * **Handlers MUST `throw new Error("Descriptive error message")` when an operation fails or an invalid state is encountered.** This is the preferred pattern over returning objects like `{ success: false, errorMessage: "..." }`.
* Use `try...catch` blocks to handle errors from underlying operations (e.g., database queries, file system access, git commands).
* Inside the `catch` block, log the original error for debugging purposes and then `throw` a new, often more user-friendly or context-specific, `Error`.
* Example:
```typescript
ipcMain.handle("list-entities", async (_, { parentId }) => {
if (!parentId) {
throw new Error("Parent ID is required to list entities.");
}
try {
const entities = await db.query.entities.findMany({ where: eq(entities.parentId, parentId) });
if (!entities) {
// Or handle as empty list depending on requirements
throw new Error(`No entities found for parent ID: ${parentId}`);
}
return entities;
} catch (error: any) {
logger.error(`Error listing entities for parent ${parentId}:`, error);
throw new Error(`Failed to list entities: ${error.message}`);
}
});
```
* **Concurrency (If Applicable):** * **Concurrency (If Applicable):**
* For operations that modify shared resources related to a specific entity (like an `appId`), use a locking mechanism (e.g., `withLock(appId, async () => { ... })`) to prevent race conditions. * For operations that modify shared resources related to a specific entity (like an `appId`), use a locking mechanism (e.g., `withLock(appId, async () => { ... })`) to prevent race conditions.

View File

@@ -0,0 +1,20 @@
CREATE TABLE `language_model_providers` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`api_base_url` text NOT NULL,
`env_var_name` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `language_models` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`provider_id` text NOT NULL,
`description` text,
`max_output_tokens` integer,
`context_window` integer,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`provider_id`) REFERENCES `language_model_providers`(`id`) ON UPDATE no action ON DELETE cascade
);

View File

@@ -0,0 +1,356 @@
{
"version": "6",
"dialect": "sqlite",
"id": "29ca03c0-a5d6-4db2-a84a-03721206fdb4",
"prevId": "ceedb797-6aa3-4a50-b42f-bc85ee08b3df",
"tables": {
"apps": {
"name": "apps",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"github_org": {
"name": "github_org",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_repo": {
"name": "github_repo",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_project_id": {
"name": "supabase_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chats": {
"name": "chats",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"initial_commit_hash": {
"name": "initial_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"chats_app_id_apps_id_fk": {
"name": "chats_app_id_apps_id_fk",
"tableFrom": "chats",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_model_providers": {
"name": "language_model_providers",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_base_url": {
"name": "api_base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"env_var_name": {
"name": "env_var_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_models": {
"name": "language_models",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_output_tokens": {
"name": "max_output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"context_window": {
"name": "context_window",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"language_models_provider_id_language_model_providers_id_fk": {
"name": "language_models_provider_id_language_model_providers_id_fk",
"tableFrom": "language_models",
"tableTo": "language_model_providers",
"columnsFrom": [
"provider_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"chat_id": {
"name": "chat_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approval_state": {
"name": "approval_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"messages_chat_id_chats_id_fk": {
"name": "messages_chat_id_chats_id_fk",
"tableFrom": "messages",
"tableTo": "chats",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -36,6 +36,13 @@
"when": 1746556241557, "when": 1746556241557,
"tag": "0004_flawless_jigsaw", "tag": "0004_flawless_jigsaw",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1747083562867,
"tag": "0005_left_thor",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,4 +1,3 @@
import { PROVIDERS } from "@/constants/models";
import { import {
Card, Card,
CardHeader, CardHeader,
@@ -7,37 +6,79 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { providerSettingsRoute } from "@/routes/settings/providers/$provider"; import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
import type { ModelProvider } from "@/lib/schemas"; import type { LanguageModelProvider } from "@/ipc/ipc_types";
import { useSettings } from "@/hooks/useSettings";
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
import { GiftIcon } from "lucide-react"; import { GiftIcon } from "lucide-react";
import { Skeleton } from "./ui/skeleton";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { AlertTriangle } from "lucide-react";
export function ProviderSettingsGrid() { export function ProviderSettingsGrid() {
const navigate = useNavigate(); const navigate = useNavigate();
const handleProviderClick = (provider: ModelProvider) => { const {
data: providers,
isLoading,
error,
isProviderSetup,
} = useLanguageModelProviders();
const handleProviderClick = (providerId: string) => {
navigate({ navigate({
to: providerSettingsRoute.id, to: providerSettingsRoute.id,
params: { provider }, params: { provider: providerId },
}); });
}; };
const { isProviderSetup } = useSettings(); if (isLoading) {
return (
<div className="p-6">
<h2 className="text-2xl font-bold mb-6">AI Providers</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5].map((i) => (
<Card key={i} className="border-border">
<CardHeader className="p-4">
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-1/2" />
</CardHeader>
</Card>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="p-6">
<h2 className="text-2xl font-bold mb-6">AI Providers</h2>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to load AI providers: {error.message}
</AlertDescription>
</Alert>
</div>
);
}
return ( return (
<div className="p-6"> <div className="p-6">
<h2 className="text-2xl font-bold mb-6">AI Providers</h2> <h2 className="text-2xl font-bold mb-6">AI Providers</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(PROVIDERS).map(([key, provider]) => { {providers?.map((provider: LanguageModelProvider) => {
return ( return (
<Card <Card
key={key} key={provider.id}
className="cursor-pointer transition-all hover:shadow-md border-border" className="cursor-pointer transition-all hover:shadow-md border-border"
onClick={() => handleProviderClick(key as ModelProvider)} onClick={() => handleProviderClick(provider.id)}
> >
<CardHeader className="p-4"> <CardHeader className="p-4">
<CardTitle className="text-xl flex items-center justify-between"> <CardTitle className="text-xl flex items-center justify-between">
{provider.displayName} {provider.name}
{isProviderSetup(key) ? ( {isProviderSetup(provider.id) ? (
<span className="ml-3 text-sm font-medium text-green-500 bg-green-50 dark:bg-green-900/30 border border-green-500/50 dark:border-green-500/50 px-2 py-1 rounded-full"> <span className="ml-3 text-sm font-medium text-green-500 bg-green-50 dark:bg-green-900/30 border border-green-500/50 dark:border-green-500/50 px-2 py-1 rounded-full">
Ready Ready
</span> </span>

View File

@@ -11,7 +11,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { providerSettingsRoute } from "@/routes/settings/providers/$provider"; import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
import { settingsRoute } from "@/routes/settings"; import { settingsRoute } from "@/routes/settings";
import { useSettings } from "@/hooks/useSettings";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { import {
@@ -24,6 +24,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { NodeSystemInfo } from "@/ipc/ipc_types"; import { NodeSystemInfo } from "@/ipc/ipc_types";
import { usePostHog } from "posthog-js/react"; import { usePostHog } from "posthog-js/react";
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
type NodeInstallStep = type NodeInstallStep =
| "install" | "install"
| "waiting-for-continue" | "waiting-for-continue"
@@ -33,7 +34,8 @@ type NodeInstallStep =
export function SetupBanner() { export function SetupBanner() {
const posthog = usePostHog(); const posthog = usePostHog();
const navigate = useNavigate(); const navigate = useNavigate();
const { isAnyProviderSetup, loading } = useSettings(); const { isAnyProviderSetup, isLoading: loading } =
useLanguageModelProviders();
const [nodeSystemInfo, setNodeSystemInfo] = useState<NodeSystemInfo | null>( const [nodeSystemInfo, setNodeSystemInfo] = useState<NodeSystemInfo | null>(
null, null,
); );

View File

@@ -20,7 +20,7 @@ export function HomeChatInput({
const posthog = usePostHog(); const posthog = usePostHog();
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom); const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const { settings, updateSettings, isAnyProviderSetup } = useSettings(); const { settings, updateSettings } = useSettings();
const { isStreaming } = useStreamChat({ const { isStreaming } = useStreamChat({
hasChatId: false, hasChatId: false,
}); // eslint-disable-line @typescript-eslint/no-unused-vars }); // eslint-disable-line @typescript-eslint/no-unused-vars
@@ -137,10 +137,7 @@ export function HomeChatInput({
) : ( ) : (
<button <button
onClick={handleCustomSubmit} onClick={handleCustomSubmit}
disabled={ disabled={!inputValue.trim() && attachments.length === 0}
(!inputValue.trim() && attachments.length === 0) ||
!isAnyProviderSetup()
}
className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50" className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
title="Start new chat" title="Start new chat"
> >

View File

@@ -3,7 +3,7 @@ import type { Message } from "@/ipc/ipc_types";
import { forwardRef, useState } from "react"; import { forwardRef, useState } from "react";
import ChatMessage from "./ChatMessage"; import ChatMessage from "./ChatMessage";
import { SetupBanner } from "../SetupBanner"; import { SetupBanner } from "../SetupBanner";
import { useSettings } from "@/hooks/useSettings";
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
@@ -14,7 +14,7 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { showError, showWarning } from "@/lib/toast"; import { showError, showWarning } from "@/lib/toast";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { chatMessagesAtom } from "@/atoms/chatAtoms"; import { chatMessagesAtom } from "@/atoms/chatAtoms";
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
interface MessagesListProps { interface MessagesListProps {
messages: Message[]; messages: Message[];
messagesEndRef: React.RefObject<HTMLDivElement | null>; messagesEndRef: React.RefObject<HTMLDivElement | null>;
@@ -25,7 +25,7 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
const appId = useAtomValue(selectedAppIdAtom); const appId = useAtomValue(selectedAppIdAtom);
const { versions, revertVersion } = useVersions(appId); const { versions, revertVersion } = useVersions(appId);
const { streamMessage, isStreaming } = useStreamChat(); const { streamMessage, isStreaming } = useStreamChat();
const { isAnyProviderSetup } = useSettings(); const { isAnyProviderSetup } = useLanguageModelProviders();
const setMessages = useSetAtom(chatMessagesAtom); const setMessages = useSetAtom(chatMessagesAtom);
const [isUndoLoading, setIsUndoLoading] = useState(false); const [isUndoLoading, setIsUndoLoading] = useState(false);

View File

@@ -9,9 +9,10 @@ import {
Settings as SettingsIcon, Settings as SettingsIcon,
GiftIcon, GiftIcon,
Trash2, Trash2,
AlertTriangle,
} from "lucide-react"; } from "lucide-react";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { PROVIDER_TO_ENV_VAR, PROVIDERS } from "@/constants/models"; 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 {
@@ -47,6 +48,13 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
updateSettings, updateSettings,
} = useSettings(); } = useSettings();
// Fetch all providers
const {
data: allProviders,
isLoading: providersLoading,
error: providersError,
} = useLanguageModelProviders();
const isDyad = provider === "auto"; const isDyad = provider === "auto";
const [apiKeyInput, setApiKeyInput] = useState(""); const [apiKeyInput, setApiKeyInput] = useState("");
@@ -54,16 +62,21 @@ 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 provider details // Find the specific provider data from the fetched list
const providerInfo = PROVIDERS[provider as keyof typeof PROVIDERS]; const providerData = allProviders?.find((p) => p.id === provider);
const providerDisplayName =
providerInfo?.displayName ||
provider.charAt(0).toUpperCase() + provider.slice(1);
const providerWebsiteUrl = providerInfo?.websiteUrl;
const hasFreeTier = providerInfo?.hasFreeTier;
const envVarName = PROVIDER_TO_ENV_VAR[provider]; // Use fetched data (or defaults for Dyad)
const envApiKey = envVars[envVarName]; const providerDisplayName = isDyad
? "Dyad"
: (providerData?.name ?? "Unknown Provider");
const providerWebsiteUrl = isDyad
? "https://academy.dyad.sh/settings"
: providerData?.websiteUrl;
const hasFreeTier = isDyad ? false : providerData?.hasFreeTier;
const envVarName = isDyad ? undefined : providerData?.envVarName;
const envApiKey = envVarName ? envVars[envVarName] : undefined;
// Use provider ID (which is the 'provider' prop)
const userApiKey = settings?.providerSettings?.[provider]?.apiKey?.value; const userApiKey = settings?.providerSettings?.[provider]?.apiKey?.value;
// --- Configuration Logic --- Updated Priority --- // --- Configuration Logic --- Updated Priority ---
@@ -169,6 +182,81 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
} }
}, [apiKeyInput]); }, [apiKeyInput]);
// --- Loading State for Providers --- (Added)
if (providersLoading) {
return (
<div className="min-h-screen px-8 py-4">
<div className="max-w-4xl mx-auto">
<Skeleton className="h-8 w-24 mb-4" /> {/* Back button */}
<Skeleton className="h-10 w-1/2 mb-6" /> {/* Title */}
<Skeleton className="h-10 w-48 mb-4" /> {/* Get Key button */}
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
</div>
</div>
);
}
// --- Error State for Providers --- (Added)
if (providersError) {
return (
<div className="min-h-screen px-8 py-4">
<div className="max-w-4xl mx-auto">
<Button
onClick={() => router.history.back()}
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>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mr-3 mb-6">
Configure Provider
</h1>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error Loading Provider Details</AlertTitle>
<AlertDescription>
Could not load provider data: {providersError.message}
</AlertDescription>
</Alert>
</div>
</div>
);
}
// Handle case where provider is not found (e.g., invalid ID in URL)
if (!providerData && !isDyad) {
return (
<div className="min-h-screen px-8 py-4">
<div className="max-w-4xl mx-auto">
<Button
onClick={() => router.history.back()}
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>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mr-3 mb-6">
Provider Not Found
</h1>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
The provider with ID "{provider}" could not be found.
</AlertDescription>
</Alert>
</div>
</div>
);
}
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">
@@ -322,7 +410,7 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
{!isDyad && ( {!isDyad && envVarName && (
<AccordionItem <AccordionItem
value="env-key" value="env-key"
className="border rounded-lg px-4 bg-(--background-lightest)" className="border rounded-lg px-4 bg-(--background-lightest)"

View File

@@ -8,7 +8,10 @@ export interface ModelOption {
contextWindow?: number; contextWindow?: number;
} }
type RegularModelProvider = Exclude<ModelProvider, "ollama" | "lmstudio">; export type RegularModelProvider = Exclude<
ModelProvider,
"ollama" | "lmstudio"
>;
export const MODEL_OPTIONS: Record<RegularModelProvider, ModelOption[]> = { export const MODEL_OPTIONS: Record<RegularModelProvider, ModelOption[]> = {
openai: [ openai: [
// https://platform.openai.com/docs/models/gpt-4.1 // https://platform.openai.com/docs/models/gpt-4.1
@@ -89,57 +92,6 @@ export const MODEL_OPTIONS: Record<RegularModelProvider, ModelOption[]> = {
], ],
}; };
export const PROVIDERS: Record<
RegularModelProvider,
{
displayName: string;
hasFreeTier?: boolean;
websiteUrl?: string;
gatewayPrefix: string;
}
> = {
openai: {
displayName: "OpenAI",
hasFreeTier: false,
websiteUrl: "https://platform.openai.com/api-keys",
gatewayPrefix: "",
},
anthropic: {
displayName: "Anthropic",
hasFreeTier: false,
websiteUrl: "https://console.anthropic.com/settings/keys",
gatewayPrefix: "anthropic/",
},
google: {
displayName: "Google",
hasFreeTier: true,
websiteUrl: "https://aistudio.google.com/app/apikey",
gatewayPrefix: "gemini/",
},
openrouter: {
displayName: "OpenRouter",
hasFreeTier: true,
websiteUrl: "https://openrouter.ai/settings/keys",
gatewayPrefix: "openrouter/",
},
auto: {
displayName: "Dyad",
websiteUrl: "https://academy.dyad.sh/settings",
gatewayPrefix: "",
},
};
export const PROVIDER_TO_ENV_VAR: Record<string, string> = {
openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
google: "GEMINI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
};
export const ALLOWED_ENV_VARS = Object.keys(PROVIDER_TO_ENV_VAR).map(
(provider) => PROVIDER_TO_ENV_VAR[provider],
);
export const AUTO_MODELS = [ export const AUTO_MODELS = [
{ {
provider: "google", provider: "google",

View File

@@ -64,3 +64,54 @@ export const messagesRelations = relations(messages, ({ one }) => ({
references: [chats.id], references: [chats.id],
}), }),
})); }));
export const language_model_providers = sqliteTable(
"language_model_providers",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
api_base_url: text("api_base_url").notNull(),
env_var_name: text("env_var_name"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
);
export const language_models = sqliteTable("language_models", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
provider_id: text("provider_id")
.notNull()
.references(() => language_model_providers.id, { onDelete: "cascade" }),
description: text("description"),
max_output_tokens: integer("max_output_tokens"),
context_window: integer("context_window"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
// Define relations for new tables
export const languageModelProvidersRelations = relations(
language_model_providers,
({ many }) => ({
languageModels: many(language_models),
}),
);
export const languageModelsRelations = relations(
language_models,
({ one }) => ({
provider: one(language_model_providers, {
fields: [language_models.provider_id],
references: [language_model_providers.id],
}),
}),
);

View File

@@ -0,0 +1,42 @@
import { useQuery } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import type { LanguageModelProvider } from "@/ipc/ipc_types";
import { useSettings } from "./useSettings";
import { cloudProviders } from "@/lib/schemas";
export function useLanguageModelProviders() {
const ipcClient = IpcClient.getInstance();
const { settings, envVars } = useSettings();
const queryResult = useQuery<LanguageModelProvider[], Error>({
queryKey: ["languageModelProviders"],
queryFn: async () => {
return ipcClient.getLanguageModelProviders();
},
});
const isProviderSetup = (provider: string) => {
const providerSettings = settings?.providerSettings[provider];
if (queryResult.isLoading) {
return false;
}
if (providerSettings?.apiKey?.value) {
return true;
}
const providerData = queryResult.data?.find((p) => p.id === provider);
if (providerData?.envVarName && envVars[providerData.envVarName]) {
return true;
}
return false;
};
const isAnyProviderSetup = () => {
return cloudProviders.some((provider) => isProviderSetup(provider));
};
return {
...queryResult,
isProviderSetup,
isAnyProviderSetup,
};
}

View File

@@ -2,15 +2,9 @@ import { useState, useEffect, useCallback } from "react";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { userSettingsAtom, envVarsAtom } from "@/atoms/appAtoms"; import { userSettingsAtom, envVarsAtom } from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { cloudProviders, type UserSettings } from "@/lib/schemas"; import { type UserSettings } from "@/lib/schemas";
import { usePostHog } from "posthog-js/react"; import { usePostHog } from "posthog-js/react";
const PROVIDER_TO_ENV_VAR: Record<string, string> = {
openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
google: "GEMINI_API_KEY",
};
const TELEMETRY_CONSENT_KEY = "dyadTelemetryConsent"; const TELEMETRY_CONSENT_KEY = "dyadTelemetryConsent";
const TELEMETRY_USER_ID_KEY = "dyadTelemetryUserId"; const TELEMETRY_USER_ID_KEY = "dyadTelemetryUserId";
@@ -81,17 +75,6 @@ export function useSettings() {
} }
}; };
const isProviderSetup = (provider: string) => {
const providerSettings = settings?.providerSettings[provider];
if (providerSettings?.apiKey?.value) {
return true;
}
if (envVars[PROVIDER_TO_ENV_VAR[provider]]) {
return true;
}
return false;
};
return { return {
settings, settings,
envVars, envVars,
@@ -99,13 +82,6 @@ export function useSettings() {
error, error,
updateSettings, updateSettings,
isProviderSetup,
isAnyProviderSetup: () => {
// Technically we should check for ollama and lmstudio being setup, but
// practically most users will want to use a cloud provider (at least
// some of the time)
return cloudProviders.some((provider) => isProviderSetup(provider));
},
refreshSettings: () => { refreshSettings: () => {
return loadInitialData(); return loadInitialData();
}, },

View File

@@ -22,7 +22,6 @@ import {
killProcess, killProcess,
removeAppIfCurrentProcess, removeAppIfCurrentProcess,
} from "../utils/process_manager"; } from "../utils/process_manager";
import { ALLOWED_ENV_VARS } from "../../constants/models";
import { getEnvVar } from "../utils/read_env"; import { getEnvVar } from "../utils/read_env";
import { readSettings } from "../../main/settings"; import { readSettings } from "../../main/settings";
@@ -33,6 +32,7 @@ import util from "util";
import log from "electron-log"; import log from "electron-log";
import { getSupabaseProjectName } from "../../supabase_admin/supabase_management_client"; import { getSupabaseProjectName } from "../../supabase_admin/supabase_management_client";
import { createLoggedHandler } from "./safe_handle"; import { createLoggedHandler } from "./safe_handle";
import { getLanguageModelProviders } from "../shared/language_model_helpers";
const logger = log.scope("app_handlers"); const logger = log.scope("app_handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
@@ -291,8 +291,11 @@ export function registerAppHandlers() {
// Do NOT use handle for this, it contains sensitive information. // Do NOT use handle for this, it contains sensitive information.
ipcMain.handle("get-env-vars", async () => { ipcMain.handle("get-env-vars", async () => {
const envVars: Record<string, string | undefined> = {}; const envVars: Record<string, string | undefined> = {};
for (const key of ALLOWED_ENV_VARS) { const providers = await getLanguageModelProviders();
envVars[key] = getEnvVar(key); for (const provider of providers) {
if (provider.envVarName) {
envVars[provider.envVarName] = getEnvVar(provider.envVarName);
}
} }
return envVars; return envVars;
}); });

View File

@@ -212,7 +212,10 @@ export function registerChatStreamHandlers() {
} else { } else {
// Normal AI processing for non-test prompts // Normal AI processing for non-test prompts
const settings = readSettings(); const settings = readSettings();
const modelClient = getModelClient(settings.selectedModel, settings); const modelClient = await getModelClient(
settings.selectedModel,
settings,
);
// Extract codebase information if app is associated with the chat // Extract codebase information if app is associated with the chat
let codebaseInfo = ""; let codebaseInfo = "";

View File

@@ -0,0 +1,16 @@
import type { LanguageModelProvider } from "@/ipc/ipc_types";
import { createLoggedHandler } from "./safe_handle";
import log from "electron-log";
import { getLanguageModelProviders } from "../shared/language_model_helpers";
const logger = log.scope("language_model_handlers");
const handle = createLoggedHandler(logger);
export function registerLanguageModelHandlers() {
handle(
"get-language-model-providers",
async (): Promise<LanguageModelProvider[]> => {
return getLanguageModelProviders();
},
);
}

View File

@@ -21,6 +21,7 @@ import type {
TokenCountResult, TokenCountResult,
ChatLogsData, ChatLogsData,
BranchResult, BranchResult,
LanguageModelProvider,
} 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";
@@ -724,13 +725,11 @@ export class IpcClient {
// Get system platform (win32, darwin, linux) // Get system platform (win32, darwin, linux)
public async getSystemPlatform(): Promise<string> { public async getSystemPlatform(): Promise<string> {
try { return this.ipcRenderer.invoke("get-system-platform");
const platform = await this.ipcRenderer.invoke("window:get-platform");
return platform;
} catch (error) {
showError(error);
throw error;
} }
public async getLanguageModelProviders(): Promise<LanguageModelProvider[]> {
return this.ipcRenderer.invoke("get-language-model-providers");
} }
// --- End window control methods --- // --- End window control methods ---

View File

@@ -14,6 +14,8 @@ import { registerTokenCountHandlers } from "./handlers/token_count_handlers";
import { registerWindowHandlers } from "./handlers/window_handlers"; import { registerWindowHandlers } from "./handlers/window_handlers";
import { registerUploadHandlers } from "./handlers/upload_handlers"; import { registerUploadHandlers } from "./handlers/upload_handlers";
import { registerVersionHandlers } from "./handlers/version_handlers"; import { registerVersionHandlers } from "./handlers/version_handlers";
import { registerLanguageModelHandlers } from "./handlers/language_model_handlers";
export function registerIpcHandlers() { export function registerIpcHandlers() {
// Register all IPC handlers by category // Register all IPC handlers by category
registerAppHandlers(); registerAppHandlers();
@@ -32,4 +34,5 @@ export function registerIpcHandlers() {
registerWindowHandlers(); registerWindowHandlers();
registerUploadHandlers(); registerUploadHandlers();
registerVersionHandlers(); registerVersionHandlers();
registerLanguageModelHandlers();
} }

View File

@@ -132,3 +132,14 @@ export interface ChatLogsData {
chat: Chat; chat: Chat;
codebase: string; codebase: string;
} }
export interface LanguageModelProvider {
id: string;
name: string;
hasFreeTier?: boolean;
websiteUrl?: string;
gatewayPrefix?: string;
envVarName?: string;
apiBaseUrl?: string;
type: "custom" | "local" | "cloud";
}

View File

@@ -0,0 +1,131 @@
import { db } from "@/db";
import { language_model_providers as languageModelProvidersSchema } from "@/db/schema";
import { RegularModelProvider } from "@/constants/models";
import type { LanguageModelProvider } from "@/ipc/ipc_types";
export const PROVIDER_TO_ENV_VAR: Record<string, string> = {
openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
google: "GEMINI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
};
export const PROVIDERS: Record<
RegularModelProvider,
{
displayName: string;
hasFreeTier?: boolean;
websiteUrl?: string;
gatewayPrefix: string;
}
> = {
openai: {
displayName: "OpenAI",
hasFreeTier: false,
websiteUrl: "https://platform.openai.com/api-keys",
gatewayPrefix: "",
},
anthropic: {
displayName: "Anthropic",
hasFreeTier: false,
websiteUrl: "https://console.anthropic.com/settings/keys",
gatewayPrefix: "anthropic/",
},
google: {
displayName: "Google",
hasFreeTier: true,
websiteUrl: "https://aistudio.google.com/app/apikey",
gatewayPrefix: "gemini/",
},
openrouter: {
displayName: "OpenRouter",
hasFreeTier: true,
websiteUrl: "https://openrouter.ai/settings/keys",
gatewayPrefix: "openrouter/",
},
auto: {
displayName: "Dyad",
websiteUrl: "https://academy.dyad.sh/settings",
gatewayPrefix: "",
},
};
/**
* Fetches language model providers from both the database (custom) and hardcoded constants (cloud),
* merging them with custom providers taking precedence.
* @returns A promise that resolves to an array of LanguageModelProvider objects.
*/
export async function getLanguageModelProviders(): Promise<
LanguageModelProvider[]
> {
// Fetch custom providers from the database
const customProvidersDb = await db
.select()
.from(languageModelProvidersSchema);
const customProvidersMap = new Map<string, LanguageModelProvider>();
for (const cp of customProvidersDb) {
customProvidersMap.set(cp.id, {
id: cp.id,
name: cp.name,
apiBaseUrl: cp.api_base_url,
envVarName: cp.env_var_name ?? undefined,
type: "custom",
// hasFreeTier, websiteUrl, gatewayPrefix are not in the custom DB schema
// They will be undefined unless overridden by hardcoded values if IDs match
});
}
// Get hardcoded cloud providers
const hardcodedProviders: LanguageModelProvider[] = [];
for (const providerKey in PROVIDERS) {
if (Object.prototype.hasOwnProperty.call(PROVIDERS, providerKey)) {
// Ensure providerKey is a key of PROVIDERS
const key = providerKey as keyof typeof PROVIDERS;
const providerDetails = PROVIDERS[key];
if (providerDetails) {
// Ensure providerDetails is not undefined
hardcodedProviders.push({
id: key,
name: providerDetails.displayName,
hasFreeTier: providerDetails.hasFreeTier,
websiteUrl: providerDetails.websiteUrl,
gatewayPrefix: providerDetails.gatewayPrefix,
envVarName: PROVIDER_TO_ENV_VAR[key] ?? undefined,
type: "cloud",
// apiBaseUrl is not directly in PROVIDERS
});
}
}
}
// Merge lists: custom providers take precedence
const mergedProvidersMap = new Map<string, LanguageModelProvider>();
// Add all hardcoded providers first
for (const hp of hardcodedProviders) {
mergedProvidersMap.set(hp.id, hp);
}
// Add/overwrite with custom providers from DB
for (const [id, cp] of customProvidersMap) {
const existingProvider = mergedProvidersMap.get(id);
if (existingProvider) {
// If exists, merge. Custom fields take precedence.
mergedProvidersMap.set(id, {
...existingProvider, // start with hardcoded
...cp, // override with custom where defined
id: cp.id, // ensure custom id is used
name: cp.name, // ensure custom name is used
type: "custom", // explicitly set type to custom
apiBaseUrl: cp.apiBaseUrl ?? existingProvider.apiBaseUrl,
envVarName: cp.envVarName ?? existingProvider.envVarName,
});
} else {
// If it doesn't exist in hardcoded, just add the custom one
mergedProvidersMap.set(id, cp);
}
}
return Array.from(mergedProvidersMap.values());
}

View File

@@ -5,36 +5,38 @@ import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { createOllama } from "ollama-ai-provider"; import { createOllama } from "ollama-ai-provider";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LargeLanguageModel, UserSettings } from "../../lib/schemas"; import type { LargeLanguageModel, UserSettings } from "../../lib/schemas";
import { import { AUTO_MODELS, MODEL_OPTIONS } from "../../constants/models";
PROVIDER_TO_ENV_VAR,
AUTO_MODELS,
PROVIDERS,
MODEL_OPTIONS,
} from "../../constants/models";
import { getEnvVar } from "./read_env"; import { getEnvVar } from "./read_env";
import log from "electron-log"; import log from "electron-log";
import { getLanguageModelProviders } from "../shared/language_model_helpers";
const logger = log.scope("getModelClient"); const logger = log.scope("getModelClient");
export function getModelClient( export async function getModelClient(
model: LargeLanguageModel, model: LargeLanguageModel,
settings: UserSettings, settings: UserSettings,
) { ) {
const allProviders = await getLanguageModelProviders();
const dyadApiKey = settings.providerSettings?.auto?.apiKey?.value; const dyadApiKey = settings.providerSettings?.auto?.apiKey?.value;
// Handle 'auto' provider by trying each model in AUTO_MODELS until one works // Handle 'auto' provider by trying each model in AUTO_MODELS until one works
if (model.provider === "auto") { if (model.provider === "auto") {
// Try each model in AUTO_MODELS in order until finding one with an API key
for (const autoModel of AUTO_MODELS) { for (const autoModel of AUTO_MODELS) {
const providerInfo = allProviders.find(
(p) => p.id === autoModel.provider,
);
const envVarName = providerInfo?.envVarName;
const apiKey = const apiKey =
dyadApiKey || dyadApiKey ||
settings.providerSettings?.[autoModel.provider]?.apiKey || settings.providerSettings?.[autoModel.provider]?.apiKey?.value ||
getEnvVar(PROVIDER_TO_ENV_VAR[autoModel.provider]); (envVarName ? getEnvVar(envVarName) : undefined);
if (apiKey) { if (apiKey) {
logger.log( logger.log(
`Using provider: ${autoModel.provider} model: ${autoModel.name}`, `Using provider: ${autoModel.provider} model: ${autoModel.name}`,
); );
// Use the first model that has an API key // Recursively call with the specific model found
return getModelClient( return await getModelClient(
{ {
provider: autoModel.provider, provider: autoModel.provider,
name: autoModel.name, name: autoModel.name,
@@ -43,27 +45,48 @@ export function getModelClient(
); );
} }
} }
// If no models have API keys, throw an error // If no models have API keys, throw an error
throw new Error("No API keys available for any model in AUTO_MODELS"); throw new Error(
"No API keys available for any model supported by the 'auto' provider.",
);
} }
// --- Handle specific provider ---
const providerConfig = allProviders.find((p) => p.id === model.provider);
if (!providerConfig) {
throw new Error(`Configuration not found for provider: ${model.provider}`);
}
// Handle Dyad Pro override
if (dyadApiKey && settings.enableDyadPro) { if (dyadApiKey && settings.enableDyadPro) {
// Check if the selected provider supports Dyad Pro (has a gateway prefix)
if (providerConfig.gatewayPrefix) {
const provider = createOpenAI({ const provider = createOpenAI({
apiKey: dyadApiKey, apiKey: dyadApiKey,
baseURL: "https://llm-gateway.dyad.sh/v1", baseURL: "https://llm-gateway.dyad.sh/v1",
}); });
const providerInfo = PROVIDERS[model.provider as keyof typeof PROVIDERS]; logger.info("Using Dyad Pro API key via Gateway");
logger.info("Using Dyad Pro API key");
// Do not use free variant (for openrouter). // Do not use free variant (for openrouter).
const modelName = model.name.split(":free")[0]; const modelName = model.name.split(":free")[0];
return provider(`${providerInfo.gatewayPrefix}${modelName}`); return provider(`${providerConfig.gatewayPrefix}${modelName}`);
} else {
logger.warn(
`Dyad Pro enabled, but provider ${model.provider} does not have a gateway prefix defined. Falling back to direct provider connection.`,
);
// Fall through to regular provider logic if gateway prefix is missing
}
} }
// Get API key for the specific provider
const apiKey = const apiKey =
settings.providerSettings?.[model.provider]?.apiKey?.value || settings.providerSettings?.[model.provider]?.apiKey?.value ||
getEnvVar(PROVIDER_TO_ENV_VAR[model.provider]); (providerConfig.envVarName
switch (model.provider) { ? getEnvVar(providerConfig.envVarName)
: undefined);
// Create client based on provider ID or type
switch (providerConfig.id) {
case "openai": { case "openai": {
const provider = createOpenAI({ apiKey }); const provider = createOpenAI({ apiKey });
return provider(model.name); return provider(model.name);
@@ -81,18 +104,38 @@ export function getModelClient(
return provider(model.name); return provider(model.name);
} }
case "ollama": { case "ollama": {
const provider = createOllama(); // Ollama typically runs locally and doesn't require an API key in the same way
const provider = createOllama({
baseURL: providerConfig.apiBaseUrl,
});
return provider(model.name); return provider(model.name);
} }
case "lmstudio": { case "lmstudio": {
// Using LM Studio's OpenAI compatible API // LM Studio uses OpenAI compatible API
const baseURL = "http://localhost:1234/v1"; // Default LM Studio OpenAI API URL const baseURL = providerConfig.apiBaseUrl || "http://localhost:1234/v1";
const provider = createOpenAICompatible({ name: "lmstudio", baseURL }); const provider = createOpenAICompatible({
name: "lmstudio",
baseURL,
});
return provider(model.name); return provider(model.name);
} }
default: { default: {
// Ensure exhaustive check if more providers are added // Handle custom providers
const _exhaustiveCheck: never = model.provider; if (providerConfig.type === "custom") {
if (!providerConfig.apiBaseUrl) {
throw new Error(
`Custom provider ${model.provider} is missing the API Base URL.`,
);
}
// Assume custom providers are OpenAI compatible for now
const provider = createOpenAICompatible({
name: providerConfig.id,
baseURL: providerConfig.apiBaseUrl,
apiKey: apiKey,
});
return provider(model.name);
}
// If it's not a known ID and not type 'custom', it's unsupported
throw new Error(`Unsupported model provider: ${model.provider}`); throw new Error(`Unsupported model provider: ${model.provider}`);
} }
} }

View File

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

View File

@@ -1,7 +1,13 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import path from "path";
// https://vitejs.dev/config // https://vitejs.dev/config
export default defineConfig({ export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: { build: {
rollupOptions: { rollupOptions: {
external: ["better-sqlite3"], external: ["better-sqlite3"],