mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-25 05:41:38 +08:00
Add multi-provider LLM support with model selection
- Support OpenRouter, OpenAI, and Anthropic providers - Fetch available models after entering API key - OpenRouter sorted by price (cheapest first) - Store provider, model, and key per user - Update reflections API to use selected provider/model Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,30 +4,58 @@ import { useState, useEffect } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { APP_NAME } from "@/lib/constants";
|
||||
import { LogOut, Users, Bell, Sparkles, Loader2, Check, Key } from "lucide-react";
|
||||
import { LogOut, Users, Bell, Sparkles, Loader2, Check, Key, ChevronDown } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface UserSettings {
|
||||
reminderEnabled: boolean;
|
||||
reminderTime: string;
|
||||
llmProvider: string | null;
|
||||
hasLlmKey: boolean;
|
||||
llmModel: string | null;
|
||||
}
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const PROVIDERS = [
|
||||
{ id: "openrouter", name: "OpenRouter", description: "Access many models, often cheapest" },
|
||||
{ id: "openai", name: "OpenAI", description: "GPT-4o, GPT-4o-mini" },
|
||||
{ id: "anthropic", name: "Anthropic", description: "Claude models" },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showApiKeyInput, setShowApiKeyInput] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [showLlmSetup, setShowLlmSetup] = useState(false);
|
||||
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>("default");
|
||||
|
||||
// LLM setup state
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
const [modelError, setModelError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
setSettings(await res.json());
|
||||
const data = await res.json();
|
||||
setSettings(data);
|
||||
if (data.llmProvider) {
|
||||
setSelectedProvider(data.llmProvider);
|
||||
}
|
||||
if (data.llmModel) {
|
||||
setSelectedModel(data.llmModel);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
@@ -37,7 +65,6 @@ export default function SettingsPage() {
|
||||
}
|
||||
loadSettings();
|
||||
|
||||
// Check notification permission
|
||||
if ("Notification" in window) {
|
||||
setNotificationPermission(Notification.permission);
|
||||
}
|
||||
@@ -75,7 +102,6 @@ export default function SettingsPage() {
|
||||
if (!settings) return;
|
||||
|
||||
if (!settings.reminderEnabled) {
|
||||
// Enabling - need permission first
|
||||
if (notificationPermission !== "granted") {
|
||||
await requestNotificationPermission();
|
||||
} else {
|
||||
@@ -86,27 +112,99 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveApiKey = async () => {
|
||||
const fetchModels = async () => {
|
||||
if (!selectedProvider || !apiKey) return;
|
||||
|
||||
setIsLoadingModels(true);
|
||||
setModelError(null);
|
||||
setModels([]);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/llm/models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider: selectedProvider, apiKey }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setModelError(data.error || "Failed to fetch models");
|
||||
return;
|
||||
}
|
||||
|
||||
setModels(data.models);
|
||||
if (data.models.length > 0) {
|
||||
setSelectedModel(data.models[0].id);
|
||||
}
|
||||
} catch {
|
||||
setModelError("Failed to connect to API");
|
||||
} finally {
|
||||
setIsLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveLlmSettings = async () => {
|
||||
if (!selectedProvider || !apiKey || !selectedModel) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ llmApiKey: apiKey }),
|
||||
body: JSON.stringify({
|
||||
llmProvider: selectedProvider,
|
||||
llmApiKey: apiKey,
|
||||
llmModel: selectedModel,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setSettings((prev) => prev ? { ...prev, hasLlmKey: Boolean(apiKey) } : null);
|
||||
setShowApiKeyInput(false);
|
||||
setSettings((prev) => prev ? {
|
||||
...prev,
|
||||
llmProvider: selectedProvider,
|
||||
hasLlmKey: true,
|
||||
llmModel: selectedModel,
|
||||
} : null);
|
||||
setShowLlmSetup(false);
|
||||
setApiKey("");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save API key:", error);
|
||||
console.error("Failed to save LLM settings:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up reminder check interval
|
||||
const clearLlmSettings = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
llmProvider: "",
|
||||
llmApiKey: "",
|
||||
llmModel: "",
|
||||
}),
|
||||
});
|
||||
setSettings((prev) => prev ? {
|
||||
...prev,
|
||||
llmProvider: null,
|
||||
hasLlmKey: false,
|
||||
llmModel: null,
|
||||
} : null);
|
||||
setSelectedProvider("");
|
||||
setSelectedModel("");
|
||||
setModels([]);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear LLM settings:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reminder check interval
|
||||
useEffect(() => {
|
||||
if (!settings?.reminderEnabled || !settings?.reminderTime) return;
|
||||
|
||||
@@ -124,11 +222,12 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Check every minute
|
||||
const interval = setInterval(checkReminder, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [settings?.reminderEnabled, settings?.reminderTime]);
|
||||
|
||||
const getProviderName = (id: string) => PROVIDERS.find(p => p.id === id)?.name || id;
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<header className="mb-8">
|
||||
@@ -153,7 +252,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin - only show for admin users */}
|
||||
{/* Admin */}
|
||||
{user?.isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
@@ -175,9 +274,7 @@ export default function SettingsPage() {
|
||||
<Bell size={20} className="text-accent" />
|
||||
<div>
|
||||
<h3 className="font-medium">Daily reminder</h3>
|
||||
<p className="text-sm text-muted">
|
||||
Get a gentle nudge to check in
|
||||
</p>
|
||||
<p className="text-sm text-muted">Get a gentle nudge to check in</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -221,16 +318,14 @@ export default function SettingsPage() {
|
||||
<Sparkles size={20} className="text-accent" />
|
||||
<div>
|
||||
<h3 className="font-medium">AI reflections</h3>
|
||||
<p className="text-sm text-muted">
|
||||
Generate insights from your entries
|
||||
</p>
|
||||
<p className="text-sm text-muted">Generate insights from your entries</p>
|
||||
</div>
|
||||
</div>
|
||||
{settings.hasLlmKey ? (
|
||||
<Check size={20} className="text-green-500" />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowApiKeyInput(true)}
|
||||
onClick={() => setShowLlmSetup(true)}
|
||||
className="px-3 py-1 text-sm bg-accent text-white rounded-lg hover:bg-accent/90"
|
||||
>
|
||||
Set up
|
||||
@@ -238,72 +333,162 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{settings.hasLlmKey && !showApiKeyInput && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-sm text-green-500">API key configured</span>
|
||||
<button
|
||||
onClick={() => setShowApiKeyInput(true)}
|
||||
className="text-sm text-muted hover:text-foreground"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<span className="text-muted">•</span>
|
||||
<Link
|
||||
href="/reflections"
|
||||
className="text-sm text-accent hover:underline"
|
||||
>
|
||||
View reflections
|
||||
</Link>
|
||||
{settings.hasLlmKey && !showLlmSetup && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-sm text-green-500">
|
||||
{getProviderName(settings.llmProvider || "")} configured
|
||||
{settings.llmModel && (
|
||||
<span className="text-muted"> • {settings.llmModel}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowLlmSetup(true)}
|
||||
className="text-sm text-muted hover:text-foreground"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
onClick={clearLlmSettings}
|
||||
className="text-sm text-red-400 hover:text-red-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<span className="text-muted">•</span>
|
||||
<Link href="/reflections" className="text-sm text-accent hover:underline">
|
||||
View reflections
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showApiKeyInput && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{showLlmSetup && (
|
||||
<div className="mt-4 space-y-4 border-t border-border pt-4">
|
||||
{/* Provider selection */}
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">
|
||||
Claude API Key
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Key size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
className="w-full pl-9 pr-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={saveApiKey}
|
||||
disabled={isSaving || !apiKey}
|
||||
className="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSaving ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
Save
|
||||
</button>
|
||||
<label className="block text-sm text-muted mb-2">Provider</label>
|
||||
<div className="grid gap-2">
|
||||
{PROVIDERS.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
onClick={() => {
|
||||
setSelectedProvider(provider.id);
|
||||
setModels([]);
|
||||
setSelectedModel("");
|
||||
setModelError(null);
|
||||
}}
|
||||
className={`p-3 text-left rounded-lg border transition-colors ${
|
||||
selectedProvider === provider.id
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border hover:border-muted"
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium">{provider.name}</p>
|
||||
<p className="text-sm text-muted">{provider.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
Get your API key from{" "}
|
||||
<a
|
||||
href="https://console.anthropic.com/settings/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
|
||||
{/* API Key */}
|
||||
{selectedProvider && (
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">API Key</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Key size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={
|
||||
selectedProvider === "anthropic" ? "sk-ant-..." :
|
||||
selectedProvider === "openai" ? "sk-..." :
|
||||
"sk-or-..."
|
||||
}
|
||||
className="w-full pl-9 pr-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchModels}
|
||||
disabled={!apiKey || isLoadingModels}
|
||||
className="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isLoadingModels ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : null}
|
||||
Load Models
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{selectedProvider === "anthropic" && (
|
||||
<>Get your key from <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">console.anthropic.com</a></>
|
||||
)}
|
||||
{selectedProvider === "openai" && (
|
||||
<>Get your key from <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">platform.openai.com</a></>
|
||||
)}
|
||||
{selectedProvider === "openrouter" && (
|
||||
<>Get your key from <a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">openrouter.ai</a></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{modelError && (
|
||||
<p className="text-sm text-red-400">{modelError}</p>
|
||||
)}
|
||||
|
||||
{/* Model selection */}
|
||||
{models.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Model</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted appearance-none pr-10"
|
||||
>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name} {model.description && `- ${model.description}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={16} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{selectedProvider === "openrouter" && "Sorted by price (cheapest first)"}
|
||||
{selectedProvider === "openai" && "Mini models are cheapest"}
|
||||
{selectedProvider === "anthropic" && "Haiku is cheapest, Opus is most capable"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLlmSetup(false);
|
||||
setApiKey("");
|
||||
setModels([]);
|
||||
setModelError(null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-muted hover:text-foreground"
|
||||
>
|
||||
console.anthropic.com
|
||||
</a>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowApiKeyInput(false);
|
||||
setApiKey("");
|
||||
}}
|
||||
className="text-sm text-muted hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
Cancel
|
||||
</button>
|
||||
{models.length > 0 && (
|
||||
<button
|
||||
onClick={saveLlmSettings}
|
||||
disabled={isSaving || !selectedModel}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{isSaving && <Loader2 size={14} className="animate-spin" />}
|
||||
Save
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user