From 1420a0d2b7180da91a04ec3d61d7e0bae5edf4f2 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 9 Jun 2026 12:36:29 -0500 Subject: [PATCH] feat(ai): model dropdown + longer generate timeout (#41) - POST /api/ai/models lists the configured endpoint's models (OpenAI-compatible /models) so the settings modal can populate a 'Load models' dropdown instead of requiring users to type the model name. Combobox (datalist) so they can still type a custom one. Admin only; same SSRF guard; uses the posted or saved key. - Bump generate-design timeout 120s -> 180s for slow local endpoints. --- frontend/js/api.js | 1 + frontend/js/i18n/en.js | 5 +++++ frontend/js/views/designer.js | 30 +++++++++++++++++++++++++++++- server/routes/ai.js | 27 ++++++++++++++++++++++++++- 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/frontend/js/api.js b/frontend/js/api.js index 0e52631..bad8ae5 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -184,6 +184,7 @@ export const api = { aiGetSettings: () => request('/ai/settings'), aiSaveSettings: (data) => request('/ai/settings', { method: 'PUT', body: JSON.stringify(data) }), aiGenerateDesign: (prompt) => request('/ai/generate-design', { method: 'POST', body: JSON.stringify({ prompt }) }), + aiListModels: (base_url, api_key) => request('/ai/models', { method: 'POST', body: JSON.stringify({ base_url, api_key }) }), // Instance-level default branding (#15, platform admin). adminGetBranding: () => request('/admin/branding'), diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index d12cd73..8e39130 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -613,6 +613,11 @@ export default { 'designer.ai.settings_desc': 'Bring your own OpenAI-compatible endpoint — OpenAI cloud, or self-hosted Ollama / LM Studio. Your provider bills you; the key is stored encrypted and never shown again.', 'designer.ai.base_url': 'Endpoint base URL', 'designer.ai.model': 'Model', + 'designer.ai.load_models': 'Load models', + 'designer.ai.loading_models': 'Loading models…', + 'designer.ai.models_loaded': 'Loaded {n} model(s) — pick one above', + 'designer.ai.models_failed': 'Could not load models', + 'designer.ai.need_base_url': 'Enter the endpoint URL first', 'designer.ai.api_key': 'API key', 'designer.ai.key_set': '•••••• saved — leave blank to keep', 'designer.ai.key_placeholder': 'leave blank if your endpoint needs none', diff --git a/frontend/js/views/designer.js b/frontend/js/views/designer.js index 840ceea..4182002 100644 --- a/frontend/js/views/designer.js +++ b/frontend/js/views/designer.js @@ -331,7 +331,12 @@ async function openAiSettings() {
-
+
+ + +
+ +
${t('designer.ai.key_hint')}
@@ -346,6 +351,29 @@ async function openAiSettings() { const close = () => overlay.remove(); overlay.querySelectorAll('[data-ai-close]').forEach(b => b.onclick = close); overlay.onclick = (e) => { if (e.target === overlay) close(); }; + + // Load the model list from the entered endpoint into the dropdown. + overlay.querySelector('#aiLoadModels').onclick = async () => { + const msg = overlay.querySelector('#aiModelMsg'); + const base_url = overlay.querySelector('#aiBaseUrl').value.trim(); + if (!base_url) { msg.style.color = 'var(--danger)'; msg.textContent = t('designer.ai.need_base_url'); return; } + const btn = overlay.querySelector('#aiLoadModels'); + btn.disabled = true; + msg.style.color = 'var(--text-muted)'; msg.textContent = t('designer.ai.loading_models'); + try { + const r = await api.aiListModels(base_url, overlay.querySelector('#aiKey').value || undefined); + const models = r.models || []; + overlay.querySelector('#aiModelList').innerHTML = models.map(m => ``).join(''); + const modelInput = overlay.querySelector('#aiModel'); + if (models.length && !modelInput.value) modelInput.value = models[0]; + msg.textContent = t('designer.ai.models_loaded', { n: models.length }); + } catch (e2) { + msg.style.color = 'var(--danger)'; msg.textContent = (e2 && e2.message) || t('designer.ai.models_failed'); + } finally { + btn.disabled = false; + } + }; + overlay.querySelector('#aiSaveSettings').onclick = async () => { const errEl = overlay.querySelector('#aiSettingsErr'); errEl.style.display = 'none'; diff --git a/server/routes/ai.js b/server/routes/ai.js index 3879831..f8b325a 100644 --- a/server/routes/ai.js +++ b/server/routes/ai.js @@ -110,6 +110,31 @@ router.put('/settings', (req, res) => { res.json({ ok: true }); }); +// POST /api/ai/models — list the models the configured/entered endpoint offers, +// for the settings dropdown. Admin only. Uses the posted key, or the saved one. +router.post('/models', async (req, res) => { + if (!isWorkspaceAdmin(req)) return res.status(403).json({ error: 'Workspace admin required' }); + const base_url = String(req.body && req.body.base_url || '').trim().replace(/\/+$/, ''); + if (!base_url) return res.status(400).json({ error: 'Endpoint base URL required' }); + if (!endpointAllowed(base_url)) return res.status(400).json({ error: 'Endpoint URL not allowed (private/internal addresses are blocked on this instance).' }); + let key = (req.body && typeof req.body.api_key === 'string' && req.body.api_key.length) ? req.body.api_key : null; + if (!key) { const row = db.prepare('SELECT api_key_enc FROM ai_settings WHERE workspace_id = ?').get(req.workspaceId); key = (row && decrypt(row.api_key_enc)) || 'none'; } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15000); + let r; + try { + r = await fetch(base_url + '/models', { headers: { Authorization: `Bearer ${key}` }, signal: controller.signal }); + } catch (e) { + clearTimeout(timer); + return res.status(502).json({ error: 'Could not reach the endpoint: ' + (e.name === 'AbortError' ? 'timed out' : e.message) }); + } + clearTimeout(timer); + if (!r.ok) { const t = await r.text().catch(() => ''); return res.status(502).json({ error: `Endpoint error ${r.status}: ${t.slice(0, 120)}` }); } + let j; try { j = await r.json(); } catch { return res.status(502).json({ error: 'Endpoint returned non-JSON.' }); } + const models = Array.isArray(j && j.data) ? j.data.map(m => m && m.id).filter(Boolean) : []; + res.json({ models: models.slice(0, 300) }); +}); + // POST /api/ai/generate-design — editor+; proxies the workspace's endpoint router.post('/generate-design', async (req, res) => { if (!canEdit(req)) return res.status(403).json({ error: 'Editor access required' }); @@ -123,7 +148,7 @@ router.post('/generate-design', async (req, res) => { const key = decrypt(row.api_key_enc) || 'none'; const url = row.base_url.replace(/\/+$/, '') + '/chat/completions'; const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 120000); + const timer = setTimeout(() => controller.abort(), 180000); // local models can be slow let aiRes; try { aiRes = await fetch(url, {