mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 02:33:15 -06:00
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:
parent
d117016f2d
commit
1420a0d2b7
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
Loading…
Reference in a new issue