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:
@@ -55,32 +55,156 @@ async def update_setting(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/test-llm")
|
@router.post("/test-llm")
|
||||||
async def test_llm_connection(current_user: User = Depends(get_current_admin)):
|
async def test_llm_connection(
|
||||||
"""Test that the configured LLM provider responds correctly."""
|
db: AsyncSession = Depends(get_db),
|
||||||
from app.services.llm_service import get_llm_provider
|
current_user: User = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""Ping the configured LLM provider with a minimal request."""
|
||||||
|
import asyncio
|
||||||
|
prov_row = await db.get(AppSetting, "llm_provider")
|
||||||
|
model_row = await db.get(AppSetting, "llm_model")
|
||||||
|
provider_name = prov_row.value if prov_row else settings.LLM_PROVIDER
|
||||||
|
model_name = model_row.value if model_row else None
|
||||||
try:
|
try:
|
||||||
provider = get_llm_provider()
|
return await asyncio.to_thread(_ping_provider, provider_name, model_name)
|
||||||
brief = provider.generate_brief(
|
except Exception as exc:
|
||||||
doc_text="This is a test bill for connection verification purposes.",
|
return {"status": "error", "detail": str(exc)}
|
||||||
bill_metadata={
|
|
||||||
"title": "Test Connection Bill",
|
|
||||||
"sponsor_name": "Test Sponsor",
|
_PING = "Reply with exactly three words: Connection test successful."
|
||||||
"party": "Test",
|
|
||||||
"state": "DC",
|
|
||||||
"chamber": "House",
|
def _ping_provider(provider_name: str, model_name: str | None) -> dict:
|
||||||
"introduced_date": "2025-01-01",
|
if provider_name == "openai":
|
||||||
"latest_action_text": "Test action",
|
from openai import OpenAI
|
||||||
"latest_action_date": "2025-01-01",
|
model = model_name or settings.OPENAI_MODEL
|
||||||
},
|
client = OpenAI(api_key=settings.OPENAI_API_KEY)
|
||||||
|
resp = client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=[{"role": "user", "content": _PING}],
|
||||||
|
max_tokens=20,
|
||||||
)
|
)
|
||||||
return {
|
reply = resp.choices[0].message.content.strip()
|
||||||
"status": "ok",
|
return {"status": "ok", "provider": "openai", "model": model, "reply": reply}
|
||||||
"provider": brief.llm_provider,
|
|
||||||
"model": brief.llm_model,
|
if provider_name == "anthropic":
|
||||||
"summary_preview": brief.summary[:100] + "..." if len(brief.summary) > 100 else brief.summary,
|
import anthropic
|
||||||
|
model = model_name or settings.ANTHROPIC_MODEL
|
||||||
|
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||||
|
resp = client.messages.create(
|
||||||
|
model=model,
|
||||||
|
max_tokens=20,
|
||||||
|
messages=[{"role": "user", "content": _PING}],
|
||||||
|
)
|
||||||
|
reply = resp.content[0].text.strip()
|
||||||
|
return {"status": "ok", "provider": "anthropic", "model": model, "reply": reply}
|
||||||
|
|
||||||
|
if provider_name == "gemini":
|
||||||
|
import google.generativeai as genai
|
||||||
|
model = model_name or settings.GEMINI_MODEL
|
||||||
|
genai.configure(api_key=settings.GEMINI_API_KEY)
|
||||||
|
resp = genai.GenerativeModel(model_name=model).generate_content(_PING)
|
||||||
|
reply = resp.text.strip()
|
||||||
|
return {"status": "ok", "provider": "gemini", "model": model, "reply": reply}
|
||||||
|
|
||||||
|
if provider_name == "ollama":
|
||||||
|
import requests as req
|
||||||
|
model = model_name or settings.OLLAMA_MODEL
|
||||||
|
resp = req.post(
|
||||||
|
f"{settings.OLLAMA_BASE_URL}/api/generate",
|
||||||
|
json={"model": model, "prompt": _PING, "stream": False},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
reply = resp.json().get("response", "").strip()
|
||||||
|
return {"status": "ok", "provider": "ollama", "model": model, "reply": reply}
|
||||||
|
|
||||||
|
raise ValueError(f"Unknown provider: {provider_name}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/llm-models")
|
||||||
|
async def list_llm_models(
|
||||||
|
provider: str,
|
||||||
|
current_user: User = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""Fetch available models directly from the provider's API."""
|
||||||
|
import asyncio
|
||||||
|
handlers = {
|
||||||
|
"openai": _list_openai_models,
|
||||||
|
"anthropic": _list_anthropic_models,
|
||||||
|
"gemini": _list_gemini_models,
|
||||||
|
"ollama": _list_ollama_models,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
fn = handlers.get(provider)
|
||||||
return {"status": "error", "detail": str(e)}
|
if not fn:
|
||||||
|
return {"models": [], "error": f"Unknown provider: {provider}"}
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(fn)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"models": [], "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def _list_openai_models() -> dict:
|
||||||
|
from openai import OpenAI
|
||||||
|
if not settings.OPENAI_API_KEY:
|
||||||
|
return {"models": [], "error": "OPENAI_API_KEY not configured"}
|
||||||
|
client = OpenAI(api_key=settings.OPENAI_API_KEY)
|
||||||
|
all_models = client.models.list().data
|
||||||
|
CHAT_PREFIXES = ("gpt-", "o1", "o3", "o4", "chatgpt-")
|
||||||
|
EXCLUDE = ("realtime", "audio", "tts", "whisper", "embedding", "dall-e", "instruct")
|
||||||
|
filtered = sorted(
|
||||||
|
[m.id for m in all_models
|
||||||
|
if any(m.id.startswith(p) for p in CHAT_PREFIXES)
|
||||||
|
and not any(x in m.id for x in EXCLUDE)],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return {"models": [{"id": m, "name": m} for m in filtered]}
|
||||||
|
|
||||||
|
|
||||||
|
def _list_anthropic_models() -> dict:
|
||||||
|
import requests as req
|
||||||
|
if not settings.ANTHROPIC_API_KEY:
|
||||||
|
return {"models": [], "error": "ANTHROPIC_API_KEY not configured"}
|
||||||
|
resp = req.get(
|
||||||
|
"https://api.anthropic.com/v1/models",
|
||||||
|
headers={
|
||||||
|
"x-api-key": settings.ANTHROPIC_API_KEY,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return {
|
||||||
|
"models": [
|
||||||
|
{"id": m["id"], "name": m.get("display_name", m["id"])}
|
||||||
|
for m in data.get("data", [])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _list_gemini_models() -> dict:
|
||||||
|
import google.generativeai as genai
|
||||||
|
if not settings.GEMINI_API_KEY:
|
||||||
|
return {"models": [], "error": "GEMINI_API_KEY not configured"}
|
||||||
|
genai.configure(api_key=settings.GEMINI_API_KEY)
|
||||||
|
models = [
|
||||||
|
{"id": m.name.replace("models/", ""), "name": m.display_name}
|
||||||
|
for m in genai.list_models()
|
||||||
|
if "generateContent" in m.supported_generation_methods
|
||||||
|
]
|
||||||
|
return {"models": sorted(models, key=lambda x: x["id"])}
|
||||||
|
|
||||||
|
|
||||||
|
def _list_ollama_models() -> dict:
|
||||||
|
import requests as req
|
||||||
|
try:
|
||||||
|
resp = req.get(f"{settings.OLLAMA_BASE_URL}/api/tags", timeout=5)
|
||||||
|
resp.raise_for_status()
|
||||||
|
tags = resp.json().get("models", [])
|
||||||
|
return {"models": [{"id": m["name"], "name": m["name"]} for m in tags]}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"models": [], "error": f"Ollama unreachable: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
def _current_model(provider: str) -> str:
|
def _current_model(provider: str) -> str:
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class Settings(BaseSettings):
|
|||||||
ANTHROPIC_MODEL: str = "claude-opus-4-6"
|
ANTHROPIC_MODEL: str = "claude-opus-4-6"
|
||||||
|
|
||||||
GEMINI_API_KEY: str = ""
|
GEMINI_API_KEY: str = ""
|
||||||
GEMINI_MODEL: str = "gemini-1.5-pro"
|
GEMINI_MODEL: str = "gemini-2.0-flash"
|
||||||
|
|
||||||
OLLAMA_BASE_URL: str = "http://host.docker.internal:11434"
|
OLLAMA_BASE_URL: str = "http://host.docker.internal:11434"
|
||||||
OLLAMA_MODEL: str = "llama3.1"
|
OLLAMA_MODEL: str = "llama3.1"
|
||||||
|
|||||||
@@ -185,10 +185,10 @@ class LLMProvider(ABC):
|
|||||||
|
|
||||||
|
|
||||||
class OpenAIProvider(LLMProvider):
|
class OpenAIProvider(LLMProvider):
|
||||||
def __init__(self):
|
def __init__(self, model: str | None = None):
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
self.client = OpenAI(api_key=settings.OPENAI_API_KEY)
|
self.client = OpenAI(api_key=settings.OPENAI_API_KEY)
|
||||||
self.model = settings.OPENAI_MODEL
|
self.model = model or settings.OPENAI_MODEL
|
||||||
|
|
||||||
def generate_brief(self, doc_text: str, bill_metadata: dict) -> ReverseBrief:
|
def generate_brief(self, doc_text: str, bill_metadata: dict) -> ReverseBrief:
|
||||||
prompt = build_prompt(doc_text, bill_metadata, MAX_TOKENS_DEFAULT)
|
prompt = build_prompt(doc_text, bill_metadata, MAX_TOKENS_DEFAULT)
|
||||||
@@ -220,10 +220,10 @@ class OpenAIProvider(LLMProvider):
|
|||||||
|
|
||||||
|
|
||||||
class AnthropicProvider(LLMProvider):
|
class AnthropicProvider(LLMProvider):
|
||||||
def __init__(self):
|
def __init__(self, model: str | None = None):
|
||||||
import anthropic
|
import anthropic
|
||||||
self.client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
|
self.client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||||
self.model = settings.ANTHROPIC_MODEL
|
self.model = model or settings.ANTHROPIC_MODEL
|
||||||
|
|
||||||
def generate_brief(self, doc_text: str, bill_metadata: dict) -> ReverseBrief:
|
def generate_brief(self, doc_text: str, bill_metadata: dict) -> ReverseBrief:
|
||||||
prompt = build_prompt(doc_text, bill_metadata, MAX_TOKENS_DEFAULT)
|
prompt = build_prompt(doc_text, bill_metadata, MAX_TOKENS_DEFAULT)
|
||||||
@@ -249,11 +249,11 @@ class AnthropicProvider(LLMProvider):
|
|||||||
|
|
||||||
|
|
||||||
class GeminiProvider(LLMProvider):
|
class GeminiProvider(LLMProvider):
|
||||||
def __init__(self):
|
def __init__(self, model: str | None = None):
|
||||||
import google.generativeai as genai
|
import google.generativeai as genai
|
||||||
genai.configure(api_key=settings.GEMINI_API_KEY)
|
genai.configure(api_key=settings.GEMINI_API_KEY)
|
||||||
self._genai = genai
|
self._genai = genai
|
||||||
self.model_name = settings.GEMINI_MODEL
|
self.model_name = model or settings.GEMINI_MODEL
|
||||||
|
|
||||||
def _make_model(self, system_prompt: str):
|
def _make_model(self, system_prompt: str):
|
||||||
return self._genai.GenerativeModel(
|
return self._genai.GenerativeModel(
|
||||||
@@ -274,9 +274,9 @@ class GeminiProvider(LLMProvider):
|
|||||||
|
|
||||||
|
|
||||||
class OllamaProvider(LLMProvider):
|
class OllamaProvider(LLMProvider):
|
||||||
def __init__(self):
|
def __init__(self, model: str | None = None):
|
||||||
self.base_url = settings.OLLAMA_BASE_URL.rstrip("/")
|
self.base_url = settings.OLLAMA_BASE_URL.rstrip("/")
|
||||||
self.model = settings.OLLAMA_MODEL
|
self.model = model or settings.OLLAMA_MODEL
|
||||||
|
|
||||||
def _generate(self, system_prompt: str, user_prompt: str) -> str:
|
def _generate(self, system_prompt: str, user_prompt: str) -> str:
|
||||||
import requests as req
|
import requests as req
|
||||||
@@ -327,15 +327,20 @@ class OllamaProvider(LLMProvider):
|
|||||||
return parse_brief_json(raw2, "ollama", self.model)
|
return parse_brief_json(raw2, "ollama", self.model)
|
||||||
|
|
||||||
|
|
||||||
def get_llm_provider() -> LLMProvider:
|
def get_llm_provider(provider: str | None = None, model: str | None = None) -> LLMProvider:
|
||||||
"""Factory — returns the configured LLM provider."""
|
"""Factory — returns the configured LLM provider.
|
||||||
provider = settings.LLM_PROVIDER.lower()
|
|
||||||
|
Pass ``provider`` and/or ``model`` explicitly (e.g. from DB overrides) to bypass env defaults.
|
||||||
|
"""
|
||||||
|
if provider is None:
|
||||||
|
provider = settings.LLM_PROVIDER
|
||||||
|
provider = provider.lower()
|
||||||
if provider == "openai":
|
if provider == "openai":
|
||||||
return OpenAIProvider()
|
return OpenAIProvider(model=model)
|
||||||
elif provider == "anthropic":
|
elif provider == "anthropic":
|
||||||
return AnthropicProvider()
|
return AnthropicProvider(model=model)
|
||||||
elif provider == "gemini":
|
elif provider == "gemini":
|
||||||
return GeminiProvider()
|
return GeminiProvider(model=model)
|
||||||
elif provider == "ollama":
|
elif provider == "ollama":
|
||||||
return OllamaProvider()
|
return OllamaProvider(model=model)
|
||||||
raise ValueError(f"Unknown LLM_PROVIDER: '{provider}'. Must be one of: openai, anthropic, gemini, ollama")
|
raise ValueError(f"Unknown LLM_PROVIDER: '{provider}'. Must be one of: openai, anthropic, gemini, ollama")
|
||||||
|
|||||||
@@ -60,7 +60,13 @@ def process_document_with_llm(self, document_id: int):
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
provider = get_llm_provider()
|
from app.models.setting import AppSetting
|
||||||
|
prov_row = db.get(AppSetting, "llm_provider")
|
||||||
|
model_row = db.get(AppSetting, "llm_model")
|
||||||
|
provider = get_llm_provider(
|
||||||
|
prov_row.value if prov_row else None,
|
||||||
|
model_row.value if model_row else None,
|
||||||
|
)
|
||||||
|
|
||||||
if previous_full_brief and previous_full_brief.document_id:
|
if previous_full_brief and previous_full_brief.document_id:
|
||||||
# New version of a bill we've already analyzed — generate amendment brief
|
# New version of a bill we've already analyzed — generate amendment brief
|
||||||
@@ -97,6 +103,9 @@ def process_document_with_llm(self, document_id: int):
|
|||||||
|
|
||||||
logger.info(f"{brief_type.capitalize()} brief {db_brief.id} created for bill {doc.bill_id} using {brief.llm_provider}/{brief.llm_model}")
|
logger.info(f"{brief_type.capitalize()} brief {db_brief.id} created for bill {doc.bill_id} using {brief.llm_provider}/{brief.llm_model}")
|
||||||
|
|
||||||
|
# Emit notification events for users who follow this bill
|
||||||
|
_emit_notification_events(db, bill, doc.bill_id, brief_type, brief.summary)
|
||||||
|
|
||||||
# Trigger news fetch now that we have topic tags
|
# Trigger news fetch now that we have topic tags
|
||||||
from app.workers.news_fetcher import fetch_news_for_bill
|
from app.workers.news_fetcher import fetch_news_for_bill
|
||||||
fetch_news_for_bill.delay(doc.bill_id)
|
fetch_news_for_bill.delay(doc.bill_id)
|
||||||
@@ -111,6 +120,35 @@ def process_document_with_llm(self, document_id: int):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_notification_events(db, bill, bill_id: str, brief_type: str, summary: str | None) -> None:
|
||||||
|
"""Create a NotificationEvent row for every user following this bill."""
|
||||||
|
from app.models.follow import Follow
|
||||||
|
from app.models.notification import NotificationEvent
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
followers = db.query(Follow).filter_by(follow_type="bill", follow_value=bill_id).all()
|
||||||
|
if not followers:
|
||||||
|
return
|
||||||
|
|
||||||
|
base_url = (settings.PUBLIC_URL or settings.LOCAL_URL).rstrip("/")
|
||||||
|
payload = {
|
||||||
|
"bill_title": bill.short_title or bill.title or "",
|
||||||
|
"bill_label": f"{bill.bill_type.upper()} {bill.bill_number}",
|
||||||
|
"brief_summary": (summary or "")[:300],
|
||||||
|
"bill_url": f"{base_url}/bills/{bill_id}",
|
||||||
|
}
|
||||||
|
event_type = "new_amendment" if brief_type == "amendment" else "new_document"
|
||||||
|
|
||||||
|
for follow in followers:
|
||||||
|
db.add(NotificationEvent(
|
||||||
|
user_id=follow.user_id,
|
||||||
|
bill_id=bill_id,
|
||||||
|
event_type=event_type,
|
||||||
|
payload=payload,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True, name="app.workers.llm_processor.backfill_brief_citations")
|
@celery_app.task(bind=True, name="app.workers.llm_processor.backfill_brief_citations")
|
||||||
def backfill_brief_citations(self):
|
def backfill_brief_citations(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
@@ -16,17 +16,21 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Brain,
|
Brain,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Bell,
|
||||||
|
Copy,
|
||||||
|
Rss,
|
||||||
} from "lucide-react";
|
} 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";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
const LLM_PROVIDERS = [
|
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: "anthropic", label: "Anthropic (Claude)", hint: "Requires ANTHROPIC_API_KEY in .env" },
|
||||||
{ value: "gemini", label: "Google Gemini", hint: "Requires GEMINI_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" },
|
{ value: "ollama", label: "Ollama (Local)", hint: "Requires Ollama running on host" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
@@ -64,11 +68,60 @@ export default function SettingsPage() {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
|
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<{
|
const [testResult, setTestResult] = useState<{
|
||||||
status: string;
|
status: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
summary_preview?: string;
|
reply?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [taskIds, setTaskIds] = useState<Record<string, string>>({});
|
const [taskIds, setTaskIds] = useState<Record<string, string>>({});
|
||||||
@@ -236,9 +289,6 @@ export default function SettingsPage() {
|
|||||||
<h2 className="font-semibold flex items-center gap-2">
|
<h2 className="font-semibold flex items-center gap-2">
|
||||||
<Cpu className="w-4 h-4" /> LLM Provider
|
<Cpu className="w-4 h-4" /> LLM Provider
|
||||||
</h2>
|
</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">
|
<div className="space-y-2">
|
||||||
{LLM_PROVIDERS.map(({ value, label, hint }) => (
|
{LLM_PROVIDERS.map(({ value, label, hint }) => (
|
||||||
<label key={value} className="flex items-start gap-3 cursor-pointer">
|
<label key={value} className="flex items-start gap-3 cursor-pointer">
|
||||||
@@ -247,7 +297,11 @@ export default function SettingsPage() {
|
|||||||
name="provider"
|
name="provider"
|
||||||
value={value}
|
value={value}
|
||||||
checked={settings?.llm_provider === 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"
|
className="mt-0.5"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
@@ -257,6 +311,76 @@ export default function SettingsPage() {
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-3 pt-2 border-t border-border">
|
||||||
<button
|
<button
|
||||||
onClick={testLLM}
|
onClick={testLLM}
|
||||||
@@ -272,7 +396,7 @@ export default function SettingsPage() {
|
|||||||
<>
|
<>
|
||||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||||
<span className="text-green-600 dark:text-green-400">
|
<span className="text-green-600 dark:text-green-400">
|
||||||
{testResult.provider} — {testResult.summary_preview?.slice(0, 60)}...
|
{testResult.model} — {testResult.reply}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -329,6 +453,113 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 */}
|
{/* Manual Controls */}
|
||||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||||
<h2 className="font-semibold">Manual Controls</h2>
|
<h2 className="font-semibold">Manual Controls</h2>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
MemberTrendScore,
|
MemberTrendScore,
|
||||||
MemberNewsArticle,
|
MemberNewsArticle,
|
||||||
NewsArticle,
|
NewsArticle,
|
||||||
|
NotificationSettings,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
SettingsData,
|
SettingsData,
|
||||||
TrendScore,
|
TrendScore,
|
||||||
@@ -111,6 +112,11 @@ export const searchAPI = {
|
|||||||
apiClient.get<{ bills: Bill[]; members: Member[] }>("/api/search", { params: { q } }).then((r) => r.data),
|
apiClient.get<{ bills: Bill[]; members: Member[] }>("/api/search", { params: { q } }).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface LLMModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
export const settingsAPI = {
|
export const settingsAPI = {
|
||||||
get: () =>
|
get: () =>
|
||||||
@@ -119,6 +125,8 @@ export const settingsAPI = {
|
|||||||
apiClient.put("/api/settings", { key, value }).then((r) => r.data),
|
apiClient.put("/api/settings", { key, value }).then((r) => r.data),
|
||||||
testLLM: () =>
|
testLLM: () =>
|
||||||
apiClient.post("/api/settings/test-llm").then((r) => r.data),
|
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 {
|
export interface AdminUser {
|
||||||
@@ -139,6 +147,16 @@ export interface AnalysisStats {
|
|||||||
remaining: number;
|
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
|
// Admin
|
||||||
export const adminAPI = {
|
export const adminAPI = {
|
||||||
// Stats
|
// Stats
|
||||||
|
|||||||
Reference in New Issue
Block a user