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 [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
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 = () => (
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={save} disabled={saving} className="gap-2">
|
||||
@@ -507,36 +534,58 @@ export function ChatbotSettingsPage() {
|
||||
<Label htmlFor="model" className="text-sm font-medium">
|
||||
Модель
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={settings.chatbot_model}
|
||||
value={models.includes(settings.chatbot_model) ? settings.chatbot_model : ""}
|
||||
onValueChange={(v) => update("chatbot_model", v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите модель" />
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
models.length > 0
|
||||
? "Выберите модель из списка провайдера"
|
||||
: "Введите модель вручную или нажмите «Загрузить модели»"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deepseek-v4-flash:preview">DeepSeek V4 Flash</SelectItem>
|
||||
<SelectItem value="deepseek-v4-pro">DeepSeek V4 Pro</SelectItem>
|
||||
<SelectItem value="deepseek-v4-flash:0731">DeepSeek V4 Flash 0731</SelectItem>
|
||||
<SelectItem value="kimi-k3">Kimi K3</SelectItem>
|
||||
<SelectItem value="kimi-k2.6">Kimi K2.6</SelectItem>
|
||||
<SelectItem value="kimi-k2.7-code">Kimi K2.7 Code</SelectItem>
|
||||
<SelectItem value="gemma4:31b">Gemma 4 31B</SelectItem>
|
||||
<SelectItem value="gpt-oss:120b">GPT-OSS 120B</SelectItem>
|
||||
<SelectItem value="gpt-oss:20b">GPT-OSS 20B</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>
|
||||
{models.length === 0 && (
|
||||
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
Список пуст — нажмите «Загрузить модели» справа
|
||||
</div>
|
||||
)}
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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">
|
||||
Доступные модели Ollama Cloud. Для Custom — введите вручную.
|
||||
Кнопка загружает список моделей из настроенного API (OpenAI-совместимый
|
||||
/models). Для Custom-провайдера введите название модели вручную.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user