feat(settings): AI model profiles management — native llama.cpp + custom OpenAI-compatible providers, default model selection, connection test
This commit is contained in:
@@ -1014,14 +1014,49 @@ async function getAiPrompt() {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
async function getAiProfiles() {
|
||||
try {
|
||||
const raw = await getSetting('ai_profiles', '[]');
|
||||
const list = JSON.parse(raw || '[]');
|
||||
return Array.isArray(list) ? list.filter(p => p && p.id && p.base_url && p.model) : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getActiveAiProfile() {
|
||||
const active = await getSetting('ai_active_profile', 'native');
|
||||
if (!active || active === 'native') return null;
|
||||
const profiles = await getAiProfiles();
|
||||
return profiles.find(p => p.id === active) || null;
|
||||
}
|
||||
|
||||
function normalizeOpenAiBase(base) {
|
||||
let b = String(base || '').trim().replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(b)) return null;
|
||||
if (!/\/v1$/i.test(b)) b += '/v1';
|
||||
return b;
|
||||
}
|
||||
|
||||
async function aiCorrectText(text) {
|
||||
const profile = await getActiveAiProfile();
|
||||
let url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
|
||||
let model = AI_MODEL;
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (profile) {
|
||||
const base = normalizeOpenAiBase(profile.base_url);
|
||||
if (!base) throw new Error('Некорректный base_url профиля ИИ');
|
||||
url = `${base}/chat/completions`;
|
||||
model = profile.model;
|
||||
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`;
|
||||
}
|
||||
try {
|
||||
const systemPrompt = await getAiPrompt();
|
||||
const res = await fetch(`${AI_URL}/v1/chat/completions`, {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: AI_MODEL,
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: text }
|
||||
@@ -3592,6 +3627,142 @@ app.post('/api/ai/correct', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- AI model profiles ---
|
||||
function validateProfileBody(body) {
|
||||
const name = String(body?.name || '').trim();
|
||||
const base = String(body?.base_url || '').trim().replace(/\/+$/, '');
|
||||
const model = String(body?.model || '').trim();
|
||||
const apiKey = String(body?.api_key || '').trim();
|
||||
if (!name || name.length > 100) return { error: 'Укажите название профиля (до 100 символов)' };
|
||||
if (!/^https?:\/\//i.test(base) || base.length > 300) return { error: 'Base URL должен быть корректным http(s)://… (до 300 символов)' };
|
||||
if (!model || model.length > 150) return { error: 'Укажите название модели (до 150 символов)' };
|
||||
if (apiKey.length > 300) return { error: 'API-ключ слишком длинный' };
|
||||
return { name, base_url: base, model, api_key: apiKey };
|
||||
}
|
||||
|
||||
app.get('/api/ai/profiles', requireAdmin, async (_, res) => {
|
||||
const profiles = await getAiProfiles();
|
||||
const active = await getSetting('ai_active_profile', 'native');
|
||||
res.json({
|
||||
active,
|
||||
native: { id: 'native', name: 'Нативная (llama.cpp в Docker)', base_url: AI_URL, model: AI_MODEL },
|
||||
profiles,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/ai/profiles', requireAdmin, async (req, res) => {
|
||||
const v = validateProfileBody(req.body);
|
||||
if (v.error) return res.status(400).json({ error: v.error });
|
||||
const profiles = await getAiProfiles();
|
||||
if (profiles.length >= 20) return res.status(400).json({ error: 'Слишком много профилей (макс. 20)' });
|
||||
const profile = { id: crypto.randomBytes(8).toString('hex'), ...v };
|
||||
profiles.push(profile);
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value) VALUES ('ai_profiles', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[JSON.stringify(profiles)]
|
||||
);
|
||||
await logAudit(req, 'ai.profile.create', { id: profile.id, name: profile.name, model: profile.model });
|
||||
invalidateSettings();
|
||||
if (entryAutoChecker) entryAutoChecker.notify();
|
||||
res.status(201).json(profile);
|
||||
});
|
||||
|
||||
app.put('/api/ai/profiles/:id', requireAdmin, async (req, res) => {
|
||||
const profiles = await getAiProfiles();
|
||||
const idx = profiles.findIndex(p => p.id === req.params.id);
|
||||
if (idx === -1) return res.status(404).json({ error: 'Профиль не найден' });
|
||||
const v = validateProfileBody(req.body);
|
||||
if (v.error) return res.status(400).json({ error: v.error });
|
||||
profiles[idx] = { id: req.params.id, ...v };
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value) VALUES ('ai_profiles', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[JSON.stringify(profiles)]
|
||||
);
|
||||
await logAudit(req, 'ai.profile.update', { id: req.params.id, name: v.name, model: v.model });
|
||||
invalidateSettings();
|
||||
if (entryAutoChecker) entryAutoChecker.notify();
|
||||
res.json(profiles[idx]);
|
||||
});
|
||||
|
||||
app.delete('/api/ai/profiles/:id', requireAdmin, async (req, res) => {
|
||||
const profiles = await getAiProfiles();
|
||||
const idx = profiles.findIndex(p => p.id === req.params.id);
|
||||
if (idx === -1) return res.status(404).json({ error: 'Профиль не найден' });
|
||||
const removed = profiles.splice(idx, 1)[0];
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value) VALUES ('ai_profiles', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[JSON.stringify(profiles)]
|
||||
);
|
||||
const active = await getSetting('ai_active_profile', 'native');
|
||||
if (active === req.params.id) {
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value) VALUES ('ai_active_profile', 'native')
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`
|
||||
);
|
||||
}
|
||||
await logAudit(req, 'ai.profile.delete', { id: req.params.id, name: removed.name });
|
||||
invalidateSettings();
|
||||
if (entryAutoChecker) entryAutoChecker.notify();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/ai/profiles/activate', requireAdmin, async (req, res) => {
|
||||
const id = String(req.body?.id || '').trim();
|
||||
if (id !== 'native') {
|
||||
const profiles = await getAiProfiles();
|
||||
if (!profiles.some(p => p.id === id)) return res.status(400).json({ error: 'Профиль не найден' });
|
||||
}
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value) VALUES ('ai_active_profile', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[id]
|
||||
);
|
||||
await logAudit(req, 'ai.profile.activate', { id });
|
||||
invalidateSettings();
|
||||
if (entryAutoChecker) entryAutoChecker.notify();
|
||||
res.json({ ok: true, active: id });
|
||||
});
|
||||
|
||||
app.post('/api/ai/profiles/test', requireAdmin, async (req, res) => {
|
||||
const v = validateProfileBody(req.body);
|
||||
if (v.error) return res.status(400).json({ error: v.error });
|
||||
const base = normalizeOpenAiBase(v.base_url);
|
||||
const startedAt = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15000);
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (v.api_key) headers.Authorization = `Bearer ${v.api_key}`;
|
||||
const r = await fetch(`${base}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: v.model,
|
||||
messages: [{ role: 'user', content: 'Ответь одним словом: ок' }],
|
||||
max_tokens: 8,
|
||||
temperature: 0,
|
||||
}),
|
||||
});
|
||||
const latency_ms = Date.now() - startedAt;
|
||||
if (!r.ok) {
|
||||
const t = await r.text().catch(() => '');
|
||||
return res.json({ ok: false, latency_ms, error: `HTTP ${r.status}${t ? ': ' + t.slice(0, 200) : ''}` });
|
||||
}
|
||||
const d = await r.json();
|
||||
const sample = (d.choices?.[0]?.message?.content || '').trim();
|
||||
res.json({ ok: true, latency_ms, sample: sample.slice(0, 120) });
|
||||
} catch (e) {
|
||||
const latency_ms = Date.now() - startedAt;
|
||||
res.json({ ok: false, latency_ms, error: e.name === 'AbortError' ? 'Таймаут 15 с' : (e.message || 'unreachable') });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/ai/queue', requireAdmin, async (_, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status`
|
||||
@@ -3599,20 +3770,32 @@ app.get('/api/ai/queue', requireAdmin, async (_, res) => {
|
||||
const counts = { pending: 0, processing: 0, done: 0, skipped: 0, error: 0, reverted: 0 };
|
||||
rows.forEach(r => { counts[r.ai_status] = r.n; });
|
||||
const enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false';
|
||||
const activeProfile = await getActiveAiProfile();
|
||||
res.json({
|
||||
enabled,
|
||||
counts,
|
||||
worker: entryAutoChecker ? entryAutoChecker.getStats() : null,
|
||||
model: AI_MODEL,
|
||||
model: activeProfile ? `${activeProfile.name} (${activeProfile.model})` : AI_MODEL,
|
||||
});
|
||||
});
|
||||
|
||||
async function aiHealthCheck() {
|
||||
const startedAt = Date.now();
|
||||
const profile = await getActiveAiProfile();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 1500);
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
try {
|
||||
const r = await fetch(`${AI_URL}/health`, { signal: controller.signal });
|
||||
const headers = {};
|
||||
let url;
|
||||
if (profile) {
|
||||
const base = normalizeOpenAiBase(profile.base_url);
|
||||
if (!base) return { reachable: false, latency_ms: 0, error: 'Некорректный base_url профиля' };
|
||||
url = `${base}/models`;
|
||||
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`;
|
||||
} else {
|
||||
url = `${AI_URL}/health`;
|
||||
}
|
||||
const r = await fetch(url, { headers, signal: controller.signal });
|
||||
const latency_ms = Date.now() - startedAt;
|
||||
if (!r.ok) return { reachable: false, latency_ms, error: `HTTP ${r.status}` };
|
||||
return { reachable: true, latency_ms, error: null };
|
||||
@@ -3655,9 +3838,10 @@ app.get('/api/ai/status', requireAdmin, async (_, res) => {
|
||||
]);
|
||||
const enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false';
|
||||
const service = await aiHealthCheck();
|
||||
const activeProfile = await getActiveAiProfile();
|
||||
res.json({
|
||||
enabled,
|
||||
model: AI_MODEL,
|
||||
model: activeProfile ? `${activeProfile.name} (${activeProfile.model})` : AI_MODEL,
|
||||
ai_url: AI_URL,
|
||||
service,
|
||||
worker: entryAutoChecker ? entryAutoChecker.getInfo() : null,
|
||||
|
||||
Reference in New Issue
Block a user