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.
This commit is contained in:
ScreenTinker 2026-06-09 12:36:29 -05:00
parent d117016f2d
commit 1420a0d2b7
4 changed files with 61 additions and 2 deletions

View file

@ -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'),

View file

@ -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',

View file

@ -331,7 +331,12 @@ async function openAiSettings() {
<div class="form-group"><label>${t('designer.ai.base_url')}</label>
<input id="aiBaseUrl" class="input" value="${esc(cur.base_url || '')}" placeholder="https://api.openai.com/v1 · http://localhost:11434/v1" style="width:100%"></div>
<div class="form-group"><label>${t('designer.ai.model')}</label>
<input id="aiModel" class="input" value="${esc(cur.model || '')}" placeholder="gpt-4o-mini · llama3.1:8b" style="width:100%"></div>
<div style="display:flex;gap:6px">
<input id="aiModel" class="input" list="aiModelList" value="${esc(cur.model || '')}" placeholder="gpt-4o-mini · llama3.1:8b" style="flex:1" autocomplete="off">
<button class="btn btn-secondary btn-sm" id="aiLoadModels" type="button" style="white-space:nowrap">${t('designer.ai.load_models')}</button>
</div>
<datalist id="aiModelList"></datalist>
<div id="aiModelMsg" style="font-size:11px;color:var(--text-muted);margin-top:4px"></div></div>
<div class="form-group"><label>${t('designer.ai.api_key')}</label>
<input id="aiKey" class="input" type="password" autocomplete="off" placeholder="${cur.has_key ? t('designer.ai.key_set') : t('designer.ai.key_placeholder')}" style="width:100%">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('designer.ai.key_hint')}</div></div>
@ -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 => `<option value="${esc(m)}"></option>`).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';

View file

@ -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, {