feat: added improved llm model selector

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-02-20 14:28:01 -08:00
parent dc19b43967
commit a3cd598e01
23 changed files with 14733 additions and 126 deletions

View file

@ -0,0 +1,262 @@
import Link from "next/link";
import { UPTIME_REPORT_URL } from "@/lib/env-config";
type UptimeStatus = "up" | "down";
interface LocationStat {
uptime_status: UptimeStatus;
response_time: number | null;
last_check: number;
}
interface UptimeMonitor {
id: string;
name: string;
type: string;
target: string;
last_check: number;
uptime_status: UptimeStatus;
monitor_status: string;
uptime: number;
locations?: Record<string, LocationStat>;
}
interface UptimeMonitorsApiResponse {
monitors?: unknown[];
}
const HETRIXTOOLS_API_BASE = "https://api.hetrixtools.com/v3";
function formatTimestamp(timestamp: number) {
if (!Number.isFinite(timestamp) || timestamp <= 0) return "n/a";
return new Date(timestamp * 1000).toLocaleString();
}
function formatLocationName(location: string) {
return location
.replaceAll("_", " ")
.split(" ")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function toNumber(value: unknown, fallback = 0) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) return parsed;
}
return fallback;
}
function normalizeUptimeStatus(value: unknown): UptimeStatus {
return value === "down" ? "down" : "up";
}
function normalizeMonitor(rawMonitor: unknown): UptimeMonitor | null {
if (!rawMonitor || typeof rawMonitor !== "object") return null;
const monitor = rawMonitor as Record<string, unknown>;
const rawLocations =
monitor.locations && typeof monitor.locations === "object"
? (monitor.locations as Record<string, unknown>)
: {};
const locations: Record<string, LocationStat> = {};
for (const [locationName, rawLocation] of Object.entries(rawLocations)) {
if (!rawLocation || typeof rawLocation !== "object") continue;
const location = rawLocation as Record<string, unknown>;
locations[locationName] = {
uptime_status: normalizeUptimeStatus(location.uptime_status),
response_time:
location.response_time === null ? null : toNumber(location.response_time, 0),
last_check: toNumber(location.last_check, 0),
};
}
return {
id: String(monitor.id ?? ""),
name: String(monitor.name ?? "Unnamed monitor"),
type: String(monitor.type ?? ""),
target: String(monitor.target ?? ""),
last_check: toNumber(monitor.last_check, 0),
uptime_status: normalizeUptimeStatus(monitor.uptime_status),
monitor_status: String(monitor.monitor_status ?? "unknown"),
uptime: toNumber(monitor.uptime, 0),
locations,
};
}
async function fetchUptimeMonitors(): Promise<{
monitors: UptimeMonitor[];
error?: string;
}> {
const apiKey = process.env.HETRIXTOOLS_API_KEY;
const monitorId = process.env.HETRIXTOOLS_MONITOR_ID;
if (!apiKey) {
return {
monitors: [],
error:
"Missing HETRIXTOOLS_API_KEY. Add it to your server environment to enable custom uptime UI.",
};
}
const query = monitorId
? `id=${encodeURIComponent(monitorId)}`
: "per_page=20&page=1&order_by=last_check&order=desc";
try {
const response = await fetch(`${HETRIXTOOLS_API_BASE}/uptime-monitors?${query}`, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
next: { revalidate: 60 },
});
if (!response.ok) {
return {
monitors: [],
error: `HetrixTools API request failed (${response.status}).`,
};
}
const data = (await response.json()) as UptimeMonitorsApiResponse;
const monitors = (data.monitors ?? [])
.map((monitor) => normalizeMonitor(monitor))
.filter((monitor): monitor is UptimeMonitor => monitor !== null);
return { monitors };
} catch {
return {
monitors: [],
error: "Could not reach HetrixTools API from the server.",
};
}
}
export default async function UptimePage() {
const { monitors, error } = await fetchUptimeMonitors();
return (
<section className="min-h-screen pt-24 pb-16">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 sm:px-6 lg:px-8">
<div className="rounded-2xl border border-neutral-200/70 bg-white/80 p-6 shadow-sm backdrop-blur-sm dark:border-neutral-800 dark:bg-neutral-950/70">
<p className="text-sm font-medium uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
System Status
</p>
<h1 className="mt-2 text-3xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100">
SurfSense uptime dashboard
</h1>
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm">
<Link
href={UPTIME_REPORT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center rounded-full border border-neutral-300 px-4 py-2 font-medium text-neutral-700 transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-900"
>
Open original report
</Link>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
Source: HetrixTools v3 API (`/uptime-monitors`).
</span>
</div>
</div>
{error ? (
<div className="rounded-2xl border border-amber-300 bg-amber-50 p-5 text-amber-900 dark:border-amber-700/70 dark:bg-amber-950/30 dark:text-amber-200">
<p className="font-semibold">Unable to load custom uptime data</p>
<p className="mt-1 text-sm">{error}</p>
</div>
) : monitors.length === 0 ? (
<div className="rounded-2xl border border-neutral-200/70 bg-white p-5 text-neutral-700 shadow-sm dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-300">
No uptime monitors returned by HetrixTools API.
</div>
) : (
<div className="grid gap-4">
{monitors.map((monitor) => {
const locations = Object.entries(monitor.locations ?? {});
const isUp = monitor.uptime_status === "up";
return (
<div
key={monitor.id}
className="rounded-2xl border border-neutral-200/70 bg-white p-5 shadow-sm dark:border-neutral-800 dark:bg-neutral-950"
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{monitor.name}
</p>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{monitor.target || "No target shown"}
</p>
</div>
<div
className={`rounded-full px-3 py-1 text-xs font-semibold ${
isUp
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300"
}`}
>
{isUp ? "Operational" : "Outage"}
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-neutral-200/70 p-3 dark:border-neutral-800">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Uptime</p>
<p className="mt-1 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{monitor.uptime.toFixed(4)}%
</p>
</div>
<div className="rounded-xl border border-neutral-200/70 p-3 dark:border-neutral-800">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Last check
</p>
<p className="mt-1 text-sm font-medium text-neutral-900 dark:text-neutral-100">
{formatTimestamp(monitor.last_check)}
</p>
</div>
<div className="rounded-xl border border-neutral-200/70 p-3 dark:border-neutral-800">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Monitor status
</p>
<p className="mt-1 text-sm font-medium capitalize text-neutral-900 dark:text-neutral-100">
{monitor.monitor_status.replaceAll("_", " ")}
</p>
</div>
</div>
{locations.length > 0 && (
<div className="mt-4">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
Locations
</p>
<div className="mt-2 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{locations.map(([locationName, locationData]) => (
<div
key={`${monitor.id}-${locationName}`}
className="rounded-xl border border-neutral-200/70 p-3 dark:border-neutral-800"
>
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{formatLocationName(locationName)}
</p>
<p className="mt-1 text-xs text-neutral-600 dark:text-neutral-400">
{locationData.uptime_status === "up" ? "Up" : "Down"} ·{" "}
{locationData.response_time ?? "n/a"} ms
</p>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</section>
);
}

View file

@ -1,4 +1,6 @@
import { atomWithQuery } from "jotai-tanstack-query";
import type { LLMModel } from "@/contracts/enums/llm-models";
import { LLM_MODELS } from "@/contracts/enums/llm-models";
import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
@ -62,3 +64,33 @@ export const defaultSystemInstructionsAtom = atomWithQuery(() => {
},
};
});
/**
* Query atom for the dynamic LLM model catalogue.
* Fetched from the backend (which proxies OpenRouter's public API).
* Falls back to the static hardcoded list on error.
*/
export const modelListAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.newLLMConfigs.modelList(),
staleTime: 60 * 60 * 1000, // 1 hour - models don't change often
placeholderData: LLM_MODELS,
queryFn: async (): Promise<LLMModel[]> => {
const data = await newLLMConfigApiService.getModels();
const dynamicModels = data.map((m) => ({
value: m.value,
label: m.label,
provider: m.provider,
contextWindow: m.context_window ?? undefined,
}));
// Providers covered by the dynamic API (from OpenRouter mapping).
// For uncovered providers (Ollama, Groq, Bedrock, etc.) keep the
// hand-curated static suggestions so users still see model options.
const coveredProviders = new Set(dynamicModels.map((m) => m.provider));
const staticFallbacks = LLM_MODELS.filter((m) => !coveredProviders.has(m.provider));
return [...dynamicModels, ...staticFallbacks];
},
};
});

View file

@ -147,7 +147,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
onClick={() => router.push(`/dashboard/${params.search_space_id}/settings`)}
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
>
<Globe className="h-4 w-4 text-muted-foreground" />
<Earth className="h-4 w-4 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>

View file

@ -13,10 +13,13 @@ import {
Sparkles,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { defaultSystemInstructionsAtom } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import {
defaultSystemInstructionsAtom,
modelListAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
@ -50,7 +53,6 @@ import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { getModelsByProvider } from "@/contracts/enums/llm-models";
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
import type { CreateNewLLMConfigRequest } from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
@ -66,7 +68,7 @@ const formSchema = z.object({
api_key: z.string().min(1, "API key is required"),
api_base: z.string().max(500).optional().nullable(),
litellm_params: z.record(z.string(), z.any()).optional().nullable(),
system_instructions: z.string().optional().default(""),
system_instructions: z.string().default(""),
use_default_system_instructions: z.boolean().default(true),
citations_enabled: z.boolean().default(true),
search_space_id: z.number(),
@ -74,7 +76,7 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
export interface LLMConfigFormData extends CreateNewLLMConfigRequest {}
export type LLMConfigFormData = CreateNewLLMConfigRequest;
interface LLMConfigFormProps {
initialData?: Partial<LLMConfigFormData>;
@ -102,12 +104,14 @@ export function LLMConfigForm({
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
defaultSystemInstructionsAtom
);
const { data: dynamicModels } = useAtomValue(modelListAtom);
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [systemInstructionsOpen, setSystemInstructionsOpen] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolver: zodResolver(formSchema) as any,
defaultValues: {
name: initialData?.name ?? "",
description: initialData?.description ?? "",
@ -138,7 +142,10 @@ export function LLMConfigForm({
const watchProvider = form.watch("provider");
const selectedProvider = LLM_PROVIDERS.find((p) => p.value === watchProvider);
const availableModels = watchProvider ? getModelsByProvider(watchProvider) : [];
const availableModels = useMemo(
() => (dynamicModels ?? []).filter((m) => m.provider === watchProvider),
[dynamicModels, watchProvider]
);
const handleProviderChange = (value: string) => {
form.setValue("provider", value);
@ -293,57 +300,58 @@ export function LLMConfigForm({
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder={selectedProvider?.example || "Type model name..."}
value={field.value}
onValueChange={field.onChange}
/>
<CommandList>
<CommandEmpty>
<div className="py-3 text-center text-sm text-muted-foreground">
{field.value ? `Using: "${field.value}"` : "Type your model name"}
</div>
</CommandEmpty>
{availableModels.length > 0 && (
<CommandGroup heading="Suggested Models">
{availableModels
.filter(
(model) =>
!field.value ||
model.value.toLowerCase().includes(field.value.toLowerCase())
)
.slice(0, 8)
.map((model) => (
<CommandItem
key={model.value}
value={model.value}
onSelect={(value) => {
field.onChange(value);
setModelComboboxOpen(false);
}}
className="py-2"
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === model.value ? "opacity-100" : "opacity-0"
)}
/>
<div>
<div className="font-medium">{model.label}</div>
{model.contextWindow && (
<div className="text-xs text-muted-foreground">
Context: {model.contextWindow}
</div>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
<Command shouldFilter={false}>
<CommandInput
placeholder={selectedProvider?.example || "Type model name..."}
value={field.value}
onValueChange={field.onChange}
/>
<CommandList className="max-h-[300px]">
<CommandEmpty>
<div className="py-3 text-center text-sm text-muted-foreground">
{field.value ? `Using: "${field.value}"` : "Type your model name"}
</div>
</CommandEmpty>
{availableModels.length > 0 && (
<CommandGroup heading="Suggested Models">
{availableModels
.filter(
(model) =>
!field.value ||
model.value.toLowerCase().includes(field.value.toLowerCase()) ||
model.label.toLowerCase().includes(field.value.toLowerCase())
)
.slice(0, 50)
.map((model) => (
<CommandItem
key={model.value}
value={model.value}
onSelect={(value) => {
field.onChange(value);
setModelComboboxOpen(false);
}}
className="py-2"
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === model.value ? "opacity-100" : "opacity-0"
)}
/>
<div>
<div className="font-medium">{model.label}</div>
{model.contextWindow && (
<div className="text-xs text-muted-foreground">
Context: {model.contextWindow}
</div>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedProvider?.example && (
@ -376,7 +384,7 @@ export function LLMConfigForm({
</FormControl>
{watchProvider === "OLLAMA" && (
<FormDescription className="text-[10px] sm:text-xs">
Ollama doesn't require auth enter any value
Ollama doesn&apos;t require auth enter any value
</FormDescription>
)}
<FormMessage />
@ -537,7 +545,7 @@ export function LLMConfigForm({
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Use {"{resolved_today}"} to include today's date dynamically
Use {"{resolved_today}"} to include today&apos;s date dynamically
</FormDescription>
<FormMessage />
</FormItem>

View file

@ -258,30 +258,6 @@ export const LLM_MODELS: LLMModel[] = [
provider: "DEEPSEEK",
contextWindow: "128K",
},
{
value: "deepseek-chat",
label: "DeepSeek Chat V3",
provider: "DEEPSEEK",
contextWindow: "66K",
},
{
value: "deepseek-v3",
label: "DeepSeek V3",
provider: "DEEPSEEK",
contextWindow: "66K",
},
{
value: "deepseek-r1",
label: "DeepSeek R1",
provider: "DEEPSEEK",
contextWindow: "66K",
},
{
value: "deepseek-r1-0528",
label: "DeepSeek R1 (0528)",
provider: "DEEPSEEK",
contextWindow: "65K",
},
// xAI (Grok)
{ value: "grok-4", label: "Grok 4", provider: "XAI", contextWindow: "256K" },
@ -1134,7 +1110,7 @@ export const LLM_MODELS: LLMModel[] = [
contextWindow: "8K",
},
{
value: "mixtral-8x7B-Instruct-v0.1",
value: "mixtral",
label: "Ollama Mixtral 8x7B",
provider: "OLLAMA",
contextWindow: "33K",
@ -1236,13 +1212,13 @@ export const LLM_MODELS: LLMModel[] = [
// Zhipu (GLM)
{
value: "z-ai/glm-4.6",
value: "glm-4.6",
label: "GLM 4.6",
provider: "ZHIPU",
contextWindow: "203K",
},
{
value: "z-ai/glm-4.6:exacto",
value: "glm-4.6:exacto",
label: "GLM 4.6 Exacto",
provider: "ZHIPU",
contextWindow: "203K",
@ -1350,7 +1326,7 @@ export const LLM_MODELS: LLMModel[] = [
contextWindow: "128K",
},
{
value: "openai/gpt-oss-120b",
value: "gpt-oss-120b",
label: "Cerebras GPT-OSS-120B",
provider: "CEREBRAS",
contextWindow: "131K",

View file

@ -128,7 +128,7 @@ export const LLM_PROVIDERS: LLMProvider[] = [
{
value: "ZHIPU",
label: "Zhipu (GLM)",
example: "openrouter/z-ai/glm-4.6",
example: "glm-4.6, glm-4.6:exacto",
description: "GLM series models",
apiBase: "https://open.bigmodel.cn/api/paas/v4",
},

View file

@ -291,6 +291,22 @@ export const updateLLMPreferencesRequest = z.object({
export const updateLLMPreferencesResponse = llmPreferences;
// =============================================================================
// Model List (dynamic catalogue from OpenRouter API)
// =============================================================================
export const modelListItem = z.object({
value: z.string(),
label: z.string(),
provider: z.string(),
context_window: z.string().nullable().optional(),
});
export const getModelListResponse = z.array(modelListItem);
export type ModelListItem = z.infer<typeof modelListItem>;
export type GetModelListResponse = z.infer<typeof getModelListResponse>;
// =============================================================================
// Type Exports
// =============================================================================

View file

@ -10,6 +10,7 @@ import {
getDefaultSystemInstructionsResponse,
getGlobalNewLLMConfigsResponse,
getLLMPreferencesResponse,
getModelListResponse,
getNewLLMConfigRequest,
getNewLLMConfigResponse,
getNewLLMConfigsRequest,
@ -145,6 +146,13 @@ class NewLLMConfigApiService {
);
};
/**
* Get the dynamic LLM model catalogue (sourced from OpenRouter API)
*/
getModels = async () => {
return baseApiService.get(`/api/v1/models`, getModelListResponse);
};
/**
* Update LLM preferences for a search space
*/

View file

@ -33,6 +33,7 @@ export const cacheKeys = {
preferences: (searchSpaceId: number) => ["llm-preferences", searchSpaceId] as const,
defaultInstructions: () => ["new-llm-configs", "default-instructions"] as const,
global: () => ["new-llm-configs", "global"] as const,
modelList: () => ["models", "catalogue"] as const,
},
imageGenConfigs: {
all: (searchSpaceId: number) => ["image-gen-configs", searchSpaceId] as const,