mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-27 10:24:48 +10:00
235 lines
6.4 KiB
TypeScript
235 lines
6.4 KiB
TypeScript
import { t } from "@lingui/core/macro";
|
|
import { Trans } from "@lingui/react/macro";
|
|
import { BrainIcon, CheckCircleIcon, InfoIcon, XCircleIcon } from "@phosphor-icons/react";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
import { createFileRoute } from "@tanstack/react-router";
|
|
import { motion } from "motion/react";
|
|
import { useMemo } from "react";
|
|
import { useIsClient } from "usehooks-ts";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { type AIProvider, useAIStore } from "@/integrations/ai/store";
|
|
import { client } from "@/integrations/orpc/client";
|
|
import { cn } from "@/utils/style";
|
|
import { DashboardHeader } from "../-components/header";
|
|
|
|
export const Route = createFileRoute("/dashboard/settings/ai")({
|
|
component: RouteComponent,
|
|
});
|
|
|
|
const providerOptions: (ComboboxOption<AIProvider> & { defaultBaseURL: string })[] = [
|
|
{
|
|
value: "openai",
|
|
label: "OpenAI",
|
|
keywords: ["openai", "gpt", "chatgpt"],
|
|
defaultBaseURL: "https://api.openai.com/v1",
|
|
},
|
|
{
|
|
value: "ollama",
|
|
label: "Ollama",
|
|
keywords: ["ollama", "ai", "local"],
|
|
defaultBaseURL: "http://localhost:11434",
|
|
},
|
|
{
|
|
value: "anthropic",
|
|
label: "Anthropic Claude",
|
|
keywords: ["anthropic", "claude", "ai"],
|
|
defaultBaseURL: "https://api.anthropic.com/v1",
|
|
},
|
|
{
|
|
value: "vercel-ai-gateway",
|
|
label: "Vercel AI Gateway",
|
|
keywords: ["vercel", "gateway", "ai"],
|
|
defaultBaseURL: "https://ai-gateway.vercel.sh/v1/ai",
|
|
},
|
|
{
|
|
value: "gemini",
|
|
label: "Google Gemini",
|
|
keywords: ["gemini", "google", "bard"],
|
|
defaultBaseURL: "https://generativelanguage.googleapis.com/v1beta",
|
|
},
|
|
];
|
|
|
|
function AIForm() {
|
|
const { set, model, apiKey, baseURL, provider, enabled, testStatus } = useAIStore();
|
|
|
|
const selectedOption = useMemo(() => {
|
|
return providerOptions.find((option) => option.value === provider);
|
|
}, [provider]);
|
|
|
|
const { mutate: testConnection, isPending: isTesting } = useMutation({
|
|
mutationFn: async () => {
|
|
if (testStatus === "success") return;
|
|
const stream = await client.ai.testConnection({ provider, model, apiKey, baseURL });
|
|
let result = "";
|
|
for await (const chunk of stream) {
|
|
result += chunk;
|
|
}
|
|
set((draft) => {
|
|
draft.testStatus = result === "1" ? "success" : "failure";
|
|
});
|
|
},
|
|
});
|
|
|
|
const handleProviderChange = (value: AIProvider | null) => {
|
|
if (!value) return;
|
|
set((draft) => {
|
|
draft.provider = value;
|
|
});
|
|
};
|
|
|
|
const handleModelChange = (value: string) => {
|
|
set((draft) => {
|
|
draft.model = value;
|
|
});
|
|
};
|
|
|
|
const handleApiKeyChange = (value: string) => {
|
|
set((draft) => {
|
|
draft.apiKey = value;
|
|
});
|
|
};
|
|
|
|
const handleBaseURLChange = (value: string) => {
|
|
set((draft) => {
|
|
draft.baseURL = value;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="grid gap-6 sm:grid-cols-2">
|
|
<div className="flex flex-col gap-y-2">
|
|
<Label htmlFor="provider">
|
|
<Trans>Provider</Trans>
|
|
</Label>
|
|
<Combobox
|
|
id="provider"
|
|
value={provider}
|
|
disabled={enabled}
|
|
options={providerOptions}
|
|
onValueChange={handleProviderChange}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-y-2">
|
|
<Label htmlFor="model">
|
|
<Trans>Model</Trans>
|
|
</Label>
|
|
<Input
|
|
id="model"
|
|
type="text"
|
|
value={model}
|
|
disabled={enabled}
|
|
onChange={(e) => handleModelChange(e.target.value)}
|
|
placeholder="e.g., gpt-4, claude-3-opus, gemini-pro"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-y-2 sm:col-span-2">
|
|
<Label htmlFor="api-key">
|
|
<Trans>API Key</Trans>
|
|
</Label>
|
|
<Input
|
|
id="api-key"
|
|
type="password"
|
|
value={apiKey}
|
|
disabled={enabled}
|
|
onChange={(e) => handleApiKeyChange(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-y-2 sm:col-span-2">
|
|
<Label htmlFor="base-url">
|
|
<Trans>Base URL (Optional)</Trans>
|
|
</Label>
|
|
<Input
|
|
id="base-url"
|
|
type="url"
|
|
value={baseURL}
|
|
disabled={enabled}
|
|
placeholder={selectedOption?.defaultBaseURL}
|
|
onChange={(e) => handleBaseURLChange(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Button variant="outline" disabled={isTesting || enabled} onClick={() => testConnection()}>
|
|
{isTesting ? (
|
|
<Spinner />
|
|
) : testStatus === "success" ? (
|
|
<CheckCircleIcon className="text-success" />
|
|
) : testStatus === "failure" ? (
|
|
<XCircleIcon className="text-destructive" />
|
|
) : null}
|
|
<Trans>Test Connection</Trans>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RouteComponent() {
|
|
const isClient = useIsClient();
|
|
|
|
const enabled = useAIStore((state) => state.enabled);
|
|
const canEnable = useAIStore((state) => state.canEnable());
|
|
const setEnabled = useAIStore((state) => state.setEnabled);
|
|
|
|
if (!isClient) return null;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<DashboardHeader icon={BrainIcon} title={t`Artificial Intelligence`} />
|
|
|
|
<Separator />
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="grid max-w-xl gap-6"
|
|
>
|
|
<div className="flex items-start gap-4 rounded-sm border bg-popover p-6">
|
|
<div className="rounded-sm bg-primary/10 p-2.5">
|
|
<InfoIcon className="text-primary" size={24} />
|
|
</div>
|
|
|
|
<div className="flex-1 space-y-2">
|
|
<h3 className="font-semibold">
|
|
<Trans>Your data is stored locally</Trans>
|
|
</h3>
|
|
|
|
<p className="text-muted-foreground leading-relaxed">
|
|
<Trans>
|
|
Everything entered here is stored locally on your browser. Your data is only sent to the server when
|
|
making a request to the AI provider, and is never stored or logged on our servers.
|
|
</Trans>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="enable-ai">
|
|
<Trans>Enable AI Features</Trans>
|
|
</Label>
|
|
<Switch id="enable-ai" checked={enabled} disabled={!canEnable} onCheckedChange={setEnabled} />
|
|
</div>
|
|
|
|
<p className={cn("flex items-center gap-x-2", enabled ? "text-success" : "text-destructive")}>
|
|
{enabled ? <CheckCircleIcon /> : <XCircleIcon />}
|
|
{enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
|
|
</p>
|
|
|
|
<AIForm />
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|