Files
Reactive-Resume/src/routes/dashboard/settings/integrations/-components/ai-section.tsx
T

285 lines
8.6 KiB
TypeScript

import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CheckCircleIcon, InfoIcon, XCircleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useMemo } from "react";
import { toast } from "sonner";
import type { AIProvider } from "@/integrations/ai/types";
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 { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { useAIStore } from "@/integrations/ai/store";
import { orpc } from "@/integrations/orpc/client";
import { getOrpcErrorMessage } from "@/utils/error-message";
import { cn } from "@/utils/style";
type AIProviderOption = ComboboxOption<AIProvider> & { defaultBaseURL: string };
const providerOptions: AIProviderOption[] = [
{
value: "openai",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "OpenAI",
}),
keywords: ["openai", "gpt", "chatgpt"],
defaultBaseURL: "https://api.openai.com/v1",
},
{
value: "anthropic",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Anthropic Claude",
}),
keywords: ["anthropic", "claude", "ai"],
defaultBaseURL: "https://api.anthropic.com/v1",
},
{
value: "gemini",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Google Gemini",
}),
keywords: ["gemini", "google", "bard"],
defaultBaseURL: "https://generativelanguage.googleapis.com/v1beta",
},
{
value: "vercel-ai-gateway",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Vercel AI Gateway",
}),
keywords: ["vercel", "gateway", "ai"],
defaultBaseURL: "https://ai-gateway.vercel.sh/v1/ai",
},
{
value: "openrouter",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "OpenRouter",
}),
keywords: ["openrouter", "router", "multi", "proxy"],
defaultBaseURL: "https://openrouter.ai/api/v1",
},
{
value: "ollama",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Ollama",
}),
keywords: ["ollama", "ai", "local"],
defaultBaseURL: "https://ollama.com/api",
},
];
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(orpc.ai.testConnection.mutationOptions());
const handleProviderChange = (value: AIProvider | null) => {
if (!value) return;
set((draft) => {
draft.provider = value;
});
};
const handleTestConnection = () => {
testConnection(
{ provider, model, apiKey, baseURL },
{
onSuccess: (data) => {
set((draft) => {
draft.testStatus = data ? "success" : "failure";
});
},
onError: (error) => {
set((draft) => {
draft.testStatus = "failure";
});
toast.error(
getOrpcErrorMessage(error, {
byCode: {
BAD_REQUEST: t({
comment: "Error shown when AI provider credentials or base URL are invalid in AI settings",
message: "Invalid AI provider configuration. Please check your settings.",
}),
BAD_GATEWAY: t({
comment: "Error shown when the configured AI provider cannot be reached during connection test",
message: "Could not reach the AI provider. Please try again.",
}),
},
fallback: t({
comment: "Fallback toast when testing AI provider connection fails",
message: "Failed to test AI provider connection. Please try again.",
}),
}),
);
},
},
);
};
return (
<div className="grid gap-6 sm:grid-cols-2">
<div className="flex flex-col gap-y-2">
<Label htmlFor="ai-provider">
<Trans>Provider</Trans>
</Label>
<Combobox
id="ai-provider"
value={provider}
disabled={enabled}
options={providerOptions}
onValueChange={handleProviderChange}
/>
</div>
<div className="flex flex-col gap-y-2">
<Label htmlFor="ai-model">
<Trans>Model</Trans>
</Label>
<Input
id="ai-model"
name="ai-model"
type="text"
value={model}
disabled={enabled}
onChange={(e) =>
set((draft) => {
draft.model = e.target.value;
})
}
placeholder={t({
comment: "Example model-name placeholder in AI settings",
message: "e.g., gpt-4, claude-3-opus, gemini-pro",
})}
autoCorrect="off"
autoComplete="off"
spellCheck="false"
autoCapitalize="off"
/>
</div>
<div className="flex flex-col gap-y-2 sm:col-span-2">
<Label htmlFor="ai-api-key">
<Trans>API Key</Trans>
</Label>
<Input
id="ai-api-key"
name="ai-api-key"
type="password"
value={apiKey}
disabled={enabled}
onChange={(e) =>
set((draft) => {
draft.apiKey = e.target.value;
})
}
autoCorrect="off"
autoComplete="off"
spellCheck="false"
autoCapitalize="off"
data-lpignore="true"
data-bwignore="true"
data-1p-ignore="true"
/>
</div>
<div className="flex flex-col gap-y-2 sm:col-span-2">
<Label htmlFor="ai-base-url">
<Trans>Base URL (Optional)</Trans>
</Label>
<Input
id="ai-base-url"
name="ai-base-url"
type="url"
value={baseURL}
disabled={enabled}
placeholder={selectedOption?.defaultBaseURL}
onChange={(e) =>
set((draft) => {
draft.baseURL = e.target.value;
})
}
autoCorrect="off"
autoComplete="off"
spellCheck="false"
autoCapitalize="off"
/>
</div>
<div>
<Button variant="outline" disabled={isTesting || enabled} onClick={handleTestConnection}>
{isTesting ? (
<Spinner />
) : testStatus === "success" ? (
<CheckCircleIcon className="text-success" />
) : testStatus === "failure" ? (
<XCircleIcon className="text-destructive" />
) : null}
<Trans>Test Connection</Trans>
</Button>
</div>
</div>
);
}
export function AISettingsSection() {
const aiEnabled = useAIStore((state) => state.enabled);
const canEnableAI = useAIStore((state) => state.canEnable());
const setAIEnabled = useAIStore((state) => state.setEnabled);
return (
<section className="grid gap-6">
<h2 className="text-lg font-semibold">
<Trans>Artificial Intelligence</Trans>
</h2>
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
<div className="rounded-md 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="leading-relaxed text-muted-foreground">
<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>
<div className="flex items-center justify-between">
<Label htmlFor="enable-ai">
<Trans>Enable AI Features</Trans>
</Label>
<Switch id="enable-ai" checked={aiEnabled} disabled={!canEnableAI} onCheckedChange={setAIEnabled} />
</div>
<p className={cn("flex items-center gap-x-2", aiEnabled ? "text-success" : "text-destructive")}>
{aiEnabled ? <CheckCircleIcon /> : <XCircleIcon />}
{aiEnabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
</p>
<AIForm />
</section>
);
}