feat(admin): load LLM models from provider /models endpoint (OpenAI-compatible)

- New API /api/chatbot/models: fetches model list from configured endpoint (/models, OpenAI/Ollama format)
- Uses saved site_settings endpoint+key, or accepts endpoint/apiKey query params (unsaved custom provider)
- UI: 'Загрузить модели' button in Provider tab — loads models into select
- Manual model input always available for Custom providers
- No more hardcoded Ollama-only model list
This commit is contained in:
NW
2026-08-08 14:01:29 +01:00
parent 9bc0bffac7
commit b352d1434c
2 changed files with 138 additions and 28 deletions

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getAuth } from '@/lib/auth-middleware';
// Загрузка списка доступных моделей от провайдера через OpenAI-совместимый /models
// GET /api/chatbot/models?endpoint=...&apiKey=...
// GET /api/chatbot/models — использует сохранённые настройки из site_settings
export async function GET(request: NextRequest) {
const auth = getAuth(request);
if ('status' in auth) return auth;
try {
const { searchParams } = request.nextUrl;
let endpoint = searchParams.get('endpoint') || '';
let apiKey = searchParams.get('apiKey') || '';
// Если endpoint не передан — берём из сохранённых настроек
if (!endpoint) {
const rows = await db.siteSetting.findMany({
where: { key: { in: ['chatbot_api_endpoint', 'chatbot_api_key'] } },
});
const settings: Record<string, string> = {};
for (const r of rows) settings[r.key] = r.value;
endpoint = settings.chatbot_api_endpoint || '';
apiKey = settings.chatbot_api_key || '';
}
if (!endpoint) {
return NextResponse.json({ error: 'API endpoint is not configured' }, { status: 400 });
}
// OpenAI-совместимый /models: берём базовый URL и добавляем /models
let base = endpoint.replace(/\/chat\/completions$/, '').replace(/\/+$/, '');
// Если в endpoint уже есть /models — используем его как есть
const modelsUrl = /\/models$/i.test(base) ? base : `${base}/models`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
const res = await fetch(modelsUrl, { headers, signal: AbortSignal.timeout(15000) });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Provider ${res.status}: ${text.slice(0, 200)}`);
}
const data = await res.json();
// OpenAI: { data: [{ id, object, owned_by, ... }] }
// Ollama: { models: [{ name, model, ... }] }
const rawModels = data?.data || data?.models || [];
const models = rawModels
.map((m: { id?: string; name?: string; model?: string }) => m.id || m.name || m.model || '')
.filter((id: string) => typeof id === 'string' && id.trim().length > 0)
.sort();
return NextResponse.json({ models, source: modelsUrl });
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to load models';
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View File

@@ -95,6 +95,8 @@ export function ChatbotSettingsPage() {
const [settings, setSettings] = useState<ChatbotSettings>(DEFAULTS); const [settings, setSettings] = useState<ChatbotSettings>(DEFAULTS);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [models, setModels] = useState<string[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
const loadSettings = useCallback(async () => { const loadSettings = useCallback(async () => {
try { try {
@@ -159,6 +161,31 @@ export function ChatbotSettingsPage() {
} }
}; };
const loadModels = async () => {
setLoadingModels(true);
try {
const qs = new URLSearchParams();
if (settings.chatbot_api_endpoint) qs.set("endpoint", settings.chatbot_api_endpoint);
if (settings.chatbot_api_key) qs.set("apiKey", settings.chatbot_api_key);
const res = await fetch(`/api/chatbot/models?${qs.toString()}`);
const data = await res.json();
if (res.ok && Array.isArray(data.models)) {
setModels(data.models);
if (data.models.length > 0) {
toast.success(`Загружено моделей: ${data.models.length}`);
} else {
toast.info("Провайдер не вернул список моделей");
}
} else {
toast.error(data.error || "Ошибка загрузки моделей");
}
} catch {
toast.error("Ошибка соединения с провайдером");
} finally {
setLoadingModels(false);
}
};
const SaveButton = () => ( const SaveButton = () => (
<div className="flex justify-end pt-4"> <div className="flex justify-end pt-4">
<Button onClick={save} disabled={saving} className="gap-2"> <Button onClick={save} disabled={saving} className="gap-2">
@@ -507,36 +534,58 @@ export function ChatbotSettingsPage() {
<Label htmlFor="model" className="text-sm font-medium"> <Label htmlFor="model" className="text-sm font-medium">
Модель Модель
</Label> </Label>
<div className="flex gap-2">
<Select <Select
value={settings.chatbot_model} value={models.includes(settings.chatbot_model) ? settings.chatbot_model : ""}
onValueChange={(v) => update("chatbot_model", v)} onValueChange={(v) => update("chatbot_model", v)}
> >
<SelectTrigger> <SelectTrigger className="flex-1">
<SelectValue placeholder="Выберите модель" /> <SelectValue
placeholder={
models.length > 0
? "Выберите модель из списка провайдера"
: "Введите модель вручную или нажмите «Загрузить модели»"
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="deepseek-v4-flash:preview">DeepSeek V4 Flash</SelectItem> {models.length === 0 && (
<SelectItem value="deepseek-v4-pro">DeepSeek V4 Pro</SelectItem> <div className="px-2 py-3 text-center text-xs text-muted-foreground">
<SelectItem value="deepseek-v4-flash:0731">DeepSeek V4 Flash 0731</SelectItem> Список пуст нажмите «Загрузить модели» справа
<SelectItem value="kimi-k3">Kimi K3</SelectItem> </div>
<SelectItem value="kimi-k2.6">Kimi K2.6</SelectItem> )}
<SelectItem value="kimi-k2.7-code">Kimi K2.7 Code</SelectItem> {models.map((m) => (
<SelectItem value="gemma4:31b">Gemma 4 31B</SelectItem> <SelectItem key={m} value={m}>
<SelectItem value="gpt-oss:120b">GPT-OSS 120B</SelectItem> {m}
<SelectItem value="gpt-oss:20b">GPT-OSS 20B</SelectItem> </SelectItem>
<SelectItem value="mistral-large-3:675b">Mistral Large 3 675B</SelectItem> ))}
<SelectItem value="nemotron-3-ultra">Nemotron 3 Ultra</SelectItem>
<SelectItem value="nemotron-3-super">Nemotron 3 Super</SelectItem>
<SelectItem value="minimax-m3">MiniMax M3</SelectItem>
<SelectItem value="minimax-m2.7">MiniMax M2.7</SelectItem>
<SelectItem value="qwen3.5:397b">Qwen 3.5 397B</SelectItem>
<SelectItem value="glm-5.2">GLM 5.2</SelectItem>
<SelectItem value="glm-5.1">GLM 5.1</SelectItem>
<SelectItem value="nemotron-3-nano:30b">Nemotron 3 Nano 30B</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button
type="button"
variant="outline"
onClick={loadModels}
disabled={loadingModels}
className="gap-2 shrink-0"
>
{loadingModels ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Sparkles className="size-4" />
)}
Загрузить модели
</Button>
</div>
<Input
id="model"
value={settings.chatbot_model}
onChange={(e) => update("chatbot_model", e.target.value)}
placeholder="Введите модель вручную, напр. deepseek-chat / gpt-4o / llama3.1:8b"
className="font-mono"
/>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Доступные модели Ollama Cloud. Для Custom введите вручную. Кнопка загружает список моделей из настроенного API (OpenAI-совместимый
/models). Для Custom-провайдера введите название модели вручную.
</p> </p>
</div> </div>