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() {
-
+
+
+
+
+
+
@@ -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, {