fix(admin): LLM provider/model switching now reads DB overrides correctly
- get_llm_provider() now accepts provider + model args so DB overrides propagate through to all provider constructors (was always reading env vars, ignoring the admin UI selection) - /test-llm replaced with lightweight ping (max_tokens=20) instead of running a full bill analysis; shows model name + reply, no truncation - /api/settings/llm-models endpoint fetches available models live from each provider's API (OpenAI, Anthropic REST, Gemini, Ollama) - Admin UI model picker dynamically populated from provider API; falls back to manual text input on error; Custom model name option kept - Default Gemini model updated: gemini-1.5-pro → gemini-2.0-flash Co-Authored-By: Jack Levy
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Settings,
|
||||
@@ -16,17 +16,21 @@ import {
|
||||
FileText,
|
||||
Brain,
|
||||
BarChart3,
|
||||
Bell,
|
||||
Copy,
|
||||
Rss,
|
||||
} from "lucide-react";
|
||||
import { settingsAPI, adminAPI, type AdminUser } from "@/lib/api";
|
||||
import { settingsAPI, adminAPI, notificationsAPI, type AdminUser, type LLMModel } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
const LLM_PROVIDERS = [
|
||||
{ value: "openai", label: "OpenAI (GPT-4o)", hint: "Requires OPENAI_API_KEY in .env" },
|
||||
{ value: "openai", label: "OpenAI", hint: "Requires OPENAI_API_KEY in .env" },
|
||||
{ value: "anthropic", label: "Anthropic (Claude)", hint: "Requires ANTHROPIC_API_KEY in .env" },
|
||||
{ value: "gemini", label: "Google Gemini", hint: "Requires GEMINI_API_KEY in .env" },
|
||||
{ value: "ollama", label: "Ollama (Local)", hint: "Requires Ollama running on host" },
|
||||
];
|
||||
|
||||
|
||||
export default function SettingsPage() {
|
||||
const qc = useQueryClient();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
@@ -64,11 +68,60 @@ export default function SettingsPage() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
|
||||
});
|
||||
|
||||
const { data: notifSettings, refetch: refetchNotif } = useQuery({
|
||||
queryKey: ["notification-settings"],
|
||||
queryFn: () => notificationsAPI.getSettings(),
|
||||
});
|
||||
|
||||
const updateNotif = useMutation({
|
||||
mutationFn: (data: Parameters<typeof notificationsAPI.updateSettings>[0]) =>
|
||||
notificationsAPI.updateSettings(data),
|
||||
onSuccess: () => refetchNotif(),
|
||||
});
|
||||
|
||||
const resetRss = useMutation({
|
||||
mutationFn: () => notificationsAPI.resetRssToken(),
|
||||
onSuccess: () => refetchNotif(),
|
||||
});
|
||||
|
||||
const [ntfyUrl, setNtfyUrl] = useState("");
|
||||
const [ntfyToken, setNtfyToken] = useState("");
|
||||
const [notifSaved, setNotifSaved] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Live model list from provider API
|
||||
const { data: modelsData, isFetching: modelsFetching, refetch: refetchModels } = useQuery({
|
||||
queryKey: ["llm-models", settings?.llm_provider],
|
||||
queryFn: () => settingsAPI.listModels(settings!.llm_provider),
|
||||
enabled: !!currentUser?.is_admin && !!settings?.llm_provider,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
const liveModels: LLMModel[] = modelsData?.models ?? [];
|
||||
const modelsError: string | undefined = modelsData?.error;
|
||||
|
||||
// Model picker state
|
||||
const [showCustomModel, setShowCustomModel] = useState(false);
|
||||
const [customModel, setCustomModel] = useState("");
|
||||
useEffect(() => {
|
||||
if (!settings || modelsFetching) return;
|
||||
const inList = liveModels.some((m) => m.id === settings.llm_model);
|
||||
if (!inList && settings.llm_model) {
|
||||
setShowCustomModel(true);
|
||||
setCustomModel(settings.llm_model);
|
||||
} else {
|
||||
setShowCustomModel(false);
|
||||
setCustomModel("");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings?.llm_provider, settings?.llm_model, modelsFetching]);
|
||||
|
||||
const [testResult, setTestResult] = useState<{
|
||||
status: string;
|
||||
detail?: string;
|
||||
summary_preview?: string;
|
||||
reply?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
} | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [taskIds, setTaskIds] = useState<Record<string, string>>({});
|
||||
@@ -236,9 +289,6 @@ export default function SettingsPage() {
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4" /> LLM Provider
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Current: <strong>{settings?.llm_provider}</strong> / <strong>{settings?.llm_model}</strong>
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{LLM_PROVIDERS.map(({ value, label, hint }) => (
|
||||
<label key={value} className="flex items-start gap-3 cursor-pointer">
|
||||
@@ -247,7 +297,11 @@ export default function SettingsPage() {
|
||||
name="provider"
|
||||
value={value}
|
||||
checked={settings?.llm_provider === value}
|
||||
onChange={() => updateSetting.mutate({ key: "llm_provider", value })}
|
||||
onChange={() => {
|
||||
updateSetting.mutate({ key: "llm_provider", value });
|
||||
setShowCustomModel(false);
|
||||
setCustomModel("");
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
@@ -257,6 +311,76 @@ export default function SettingsPage() {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Model picker — live from provider API */}
|
||||
<div className="space-y-2 pt-3 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Model</label>
|
||||
{modelsFetching && <span className="text-xs text-muted-foreground">Loading models…</span>}
|
||||
{modelsError && !modelsFetching && (
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400">{modelsError}</span>
|
||||
)}
|
||||
{!modelsFetching && liveModels.length > 0 && (
|
||||
<button onClick={() => refetchModels()} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{liveModels.length > 0 ? (
|
||||
<select
|
||||
value={showCustomModel ? "__custom__" : (settings?.llm_model ?? "")}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "__custom__") {
|
||||
setShowCustomModel(true);
|
||||
setCustomModel(settings?.llm_model ?? "");
|
||||
} else {
|
||||
setShowCustomModel(false);
|
||||
setCustomModel("");
|
||||
updateSetting.mutate({ key: "llm_model", value: e.target.value });
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-sm bg-background border border-border rounded-md"
|
||||
>
|
||||
{liveModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name !== m.id ? `${m.name} (${m.id})` : m.id}</option>
|
||||
))}
|
||||
<option value="__custom__">Custom model name…</option>
|
||||
</select>
|
||||
) : (
|
||||
!modelsFetching && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{modelsError ? "Could not fetch models — enter a model name manually below." : "No models found."}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
{(showCustomModel || (liveModels.length === 0 && !modelsFetching)) && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. gpt-4o or gemini-2.0-flash"
|
||||
value={customModel}
|
||||
onChange={(e) => setCustomModel(e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (customModel.trim()) updateSetting.mutate({ key: "llm_model", value: customModel.trim() });
|
||||
}}
|
||||
disabled={!customModel.trim() || updateSetting.isPending}
|
||||
className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Active: <strong>{settings?.llm_provider}</strong> / <strong>{settings?.llm_model}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2 border-t border-border">
|
||||
<button
|
||||
onClick={testLLM}
|
||||
@@ -272,7 +396,7 @@ export default function SettingsPage() {
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
{testResult.provider} — {testResult.summary_preview?.slice(0, 60)}...
|
||||
{testResult.model} — {testResult.reply}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -329,6 +453,113 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Notifications */}
|
||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
<Bell className="w-4 h-4" /> Notifications
|
||||
</h2>
|
||||
|
||||
{/* ntfy */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">ntfy Topic URL</label>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">
|
||||
Your ntfy topic — use <a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy.sh</a> (public) or your self-hosted server.
|
||||
e.g. <code className="bg-muted px-1 rounded text-xs">https://ntfy.sh/your-topic</code>
|
||||
</p>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://ntfy.sh/your-topic"
|
||||
defaultValue={notifSettings?.ntfy_topic_url ?? ""}
|
||||
onChange={(e) => setNtfyUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">ntfy Auth Token <span className="text-muted-foreground font-normal">(optional)</span></label>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Required only for private/self-hosted topics with access control.</p>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="tk_..."
|
||||
defaultValue={notifSettings?.ntfy_token ?? ""}
|
||||
onChange={(e) => setNtfyToken(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
updateNotif.mutate({
|
||||
ntfy_topic_url: ntfyUrl || notifSettings?.ntfy_topic_url || "",
|
||||
ntfy_token: ntfyToken || notifSettings?.ntfy_token || "",
|
||||
ntfy_enabled: true,
|
||||
}, {
|
||||
onSuccess: () => { setNotifSaved(true); setTimeout(() => setNotifSaved(false), 2000); }
|
||||
});
|
||||
}}
|
||||
disabled={updateNotif.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{notifSaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
|
||||
{notifSaved ? "Saved!" : "Save & Enable"}
|
||||
</button>
|
||||
{notifSettings?.ntfy_enabled && (
|
||||
<button
|
||||
onClick={() => updateNotif.mutate({ ntfy_enabled: false })}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
)}
|
||||
{notifSettings?.ntfy_enabled && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="w-3 h-3" /> ntfy active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RSS */}
|
||||
<div className="pt-3 border-t border-border space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Rss className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">RSS Feed</span>
|
||||
</div>
|
||||
{notifSettings?.rss_token ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs bg-muted px-2 py-1.5 rounded truncate">
|
||||
{`${window.location.origin}/api/notifications/feed/${notifSettings.rss_token}.xml`}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/api/notifications/feed/${notifSettings.rss_token}.xml`
|
||||
);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
className="shrink-0 p-1.5 rounded hover:bg-accent transition-colors"
|
||||
title="Copy RSS URL"
|
||||
>
|
||||
{copied ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => resetRss.mutate()}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Regenerate URL (invalidates old link)
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Save your ntfy settings above to generate your personal RSS feed URL.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Manual Controls */}
|
||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<h2 className="font-semibold">Manual Controls</h2>
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
MemberTrendScore,
|
||||
MemberNewsArticle,
|
||||
NewsArticle,
|
||||
NotificationSettings,
|
||||
PaginatedResponse,
|
||||
SettingsData,
|
||||
TrendScore,
|
||||
@@ -111,6 +112,11 @@ export const searchAPI = {
|
||||
apiClient.get<{ bills: Bill[]; members: Member[] }>("/api/search", { params: { q } }).then((r) => r.data),
|
||||
};
|
||||
|
||||
export interface LLMModel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Settings
|
||||
export const settingsAPI = {
|
||||
get: () =>
|
||||
@@ -119,6 +125,8 @@ export const settingsAPI = {
|
||||
apiClient.put("/api/settings", { key, value }).then((r) => r.data),
|
||||
testLLM: () =>
|
||||
apiClient.post("/api/settings/test-llm").then((r) => r.data),
|
||||
listModels: (provider: string) =>
|
||||
apiClient.get<{ models: LLMModel[]; error?: string }>("/api/settings/llm-models", { params: { provider } }).then((r) => r.data),
|
||||
};
|
||||
|
||||
export interface AdminUser {
|
||||
@@ -139,6 +147,16 @@ export interface AnalysisStats {
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
// Notifications
|
||||
export const notificationsAPI = {
|
||||
getSettings: () =>
|
||||
apiClient.get<NotificationSettings>("/api/notifications/settings").then((r) => r.data),
|
||||
updateSettings: (data: Partial<NotificationSettings>) =>
|
||||
apiClient.put<NotificationSettings>("/api/notifications/settings", data).then((r) => r.data),
|
||||
resetRssToken: () =>
|
||||
apiClient.post<NotificationSettings>("/api/notifications/settings/rss-reset").then((r) => r.data),
|
||||
};
|
||||
|
||||
// Admin
|
||||
export const adminAPI = {
|
||||
// Stats
|
||||
|
||||
Reference in New Issue
Block a user