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:
61
admin-next/src/app/api/chatbot/models/route.ts
Normal file
61
admin-next/src/app/api/chatbot/models/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user