From 5b4d9324a440373f0a189639d3c03e4c75496c0e Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 24 Jun 2026 14:35:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=D0=BF=D0=BE=D0=B4=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=BA=D0=B0=20keyless-=D1=88=D0=BB=D1=8E?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=20+=20=D0=BF=D1=80=D0=B5=D1=81=D0=B5=D1=82?= =?UTF-8?q?=20Pollinations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pollinations (text.pollinations.ai/openai, модель openai) даёт бесплатный инференс БЕЗ ключа — проверено: 98% чистый русский. Чтобы такой провайдер считался рабочим (раньше ключ требовался всем, кроме localhost): - _noKeyNeeded/_aNoKey: localhost ИЛИ pollinations.ai → ключ не обязателен (используется в providersOrdered, pingLLM, active-check, testAssistant) - пресет «Pollinations (без ключа)» в ASSISTANT_PRESETS - бейдж провайдера: «без ключа» (зелёный) вместо «нет ключа» для keyless Кейд-провайдеры (Kilo/Gemini/HF/…) по-прежнему требуют ключ — затронуты только URL с pollinations.ai (спуф в пути отвергается). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/controllers/adminController.js | 9 ++++++--- backend/src/controllers/assistantController.js | 6 ++++-- frontend/js/admin/sections/assistant.js | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 406aecc..bc9a54b 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -940,6 +940,7 @@ const ASSISTANT_PRESETS = [ { name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile' }, { name: 'OpenRouter', url: 'https://openrouter.ai/api/v1/chat/completions', model: 'meta-llama/llama-3.3-70b-instruct:free' }, { name: 'HuggingFace Router', url: 'https://router.huggingface.co/v1/chat/completions', model: 'Qwen/Qwen2.5-72B-Instruct' }, + { name: 'Pollinations (без ключа)', url: 'https://text.pollinations.ai/openai', model: 'openai' }, { name: 'Ollama (локально)', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:3b' }, ]; // Проверенные бесплатные модели шлюза Kilo (отдают чистый русский). Порядок — от мощных к лёгким. @@ -967,6 +968,8 @@ function _kiloModels() { function _aProviders() { try { return JSON.parse(_aset('assistant_providers') || '[]') || []; } catch (e) { return []; } } function _aSetProviders(arr) { db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_providers', ?)").run(JSON.stringify(arr)); } function _aIsLocal(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || ''); } +// Шлюзы с бесплатным инференсом без ключа (как localhost): ключ не обязателен. +function _aNoKey(u) { return _aIsLocal(u) || /\/\/[^/]*\bpollinations\.ai\b/i.test(u || ''); } function getAssistant(_req, res) { // Миграция legacy-настроек в список провайдеров (один раз) @@ -980,10 +983,10 @@ function getAssistant(_req, res) { } } const list = _aProviders(); - const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key, ctx: p.ctx || null, out: p.out || null, free: (typeof p.free === 'boolean' ? p.free : null) })); + const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key, noKey: _aNoKey(p.url), ctx: p.ctx || null, out: p.out || null, free: (typeof p.free === 'boolean' ? p.free : null) })); const activeId = _aset('assistant_active') || (providers[0] && providers[0].id) || null; const ap = list.find(p => p.id === activeId); - const active = !!(ap && (ap.key || _aIsLocal(ap.url))); + const active = !!(ap && (ap.key || _aNoKey(ap.url))); let chunks = 0, usage = { model_calls: 0, cache_hits: 0, faq: 0 }, usage30 = { model_calls: 0, cache_hits: 0, faq: 0 }; try { chunks = db.prepare('SELECT COUNT(*) n FROM textbook_chunks').get().n; } catch (e) {} @@ -1243,7 +1246,7 @@ async function testAssistant(req, res) { }; } override.local = _aIsLocal(override.url); - override.on = !!(override.key || override.local); + override.on = !!(override.key || _aNoKey(override.url)); const r = await a.pingLLM(override); // Успешный тест активного провайдера снимает устаревший флаг failover try { const activeId = _aset('assistant_active'); if (r && r.ok && (!b.id || b.id === activeId)) a.clearFailover(); } catch (e) {} diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index 3e684ad..a31f897 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -339,6 +339,8 @@ function searchFaq(q, n) { * на ENV и дефолты. Если ключа нет и URL не локальный — работаем как FAQ. */ function _setting(k) { try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } catch (e) { return null; } } function _isLocal(url) { return /\/\/(localhost|127\.0\.0\.1)/.test(url || ''); } +// Шлюзы с бесплатным инференсом БЕЗ ключа (наряду с localhost): ключ не обязателен. +function _noKeyNeeded(url) { return _isLocal(url) || /\/\/[^/]*\bpollinations\.ai\b/i.test(url || ''); } /* Список провайдеров (несколько ключей/моделей). Хранится JSON в app_settings. * Если списка нет — синтезируем из legacy-настроек/ENV, чтобы ничего не сломать. */ @@ -357,7 +359,7 @@ function _providers() { /* Конфиги в порядке использования: активный первым, затем остальные с ключом * (для авто-перехвата при лимите/ошибке). */ function providersOrdered() { - const arr = _providers().filter(p => p && (p.key || _isLocal(p.url))); + const arr = _providers().filter(p => p && (p.key || _noKeyNeeded(p.url))); const activeId = _setting('assistant_active'); const active = arr.filter(p => p.id === activeId); const rest = arr.filter(p => p.id !== activeId); @@ -455,7 +457,7 @@ async function callLLMFailover(messages, maxTokens) { async function pingLLM(override) { const cfg = override || llmConfig(); if (!cfg.url) return { ok: false, error: 'URL не задан' }; - if (!cfg.key && !/\/\/(localhost|127\.0\.0\.1)/.test(cfg.url)) return { ok: false, error: 'Ключ не задан' }; + if (!cfg.key && !_noKeyNeeded(cfg.url)) return { ok: false, error: 'Ключ не задан' }; if (typeof fetch !== 'function') return { ok: false, error: 'fetch недоступен' }; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 15000); diff --git a/frontend/js/admin/sections/assistant.js b/frontend/js/admin/sections/assistant.js index 106da44..4cab1e5 100644 --- a/frontend/js/admin/sections/assistant.js +++ b/frontend/js/admin/sections/assistant.js @@ -161,7 +161,7 @@ '
' + SPARK + '
' + '
' + esc(p.name || 'Провайдер') + (act ? 'активен' : '') + - '' + (p.hasKey ? 'ключ есть' : 'нет ключа') + '
' + + (p.hasKey ? 'ключ есть' : p.noKey ? 'без ключа' : 'нет ключа') + '
' + '
' + esc(p.model || '') + '
' + ksel + lim + '' + '
' + (act ? '' : '') +