Changeset 0.37.5 (#217)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
199
src/js/sw/surfaces/settings/providers.js
Normal file
199
src/js/sw/surfaces/settings/providers.js
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* ProvidersSection — BYOK provider CRUD (user-scoped)
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s); }
|
||||
|
||||
export function ProvidersSection() {
|
||||
const [providers, setProviders] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [form, setForm] = useState({ name: '', provider: 'openai', endpoint: '', api_key: '', model_default: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.providers.list();
|
||||
setProviders(resp?.configs || resp?.data || resp || []);
|
||||
} catch (e) {
|
||||
setProviders([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openAdd = useCallback(() => {
|
||||
setEditId(null);
|
||||
setForm({ name: '', provider: 'openai', endpoint: '', api_key: '', model_default: '' });
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback(async (prov) => {
|
||||
try {
|
||||
const cfg = await sw.api.providers.get(prov.id);
|
||||
setEditId(cfg.id);
|
||||
setForm({
|
||||
name: cfg.name || '',
|
||||
provider: cfg.provider || 'openai',
|
||||
endpoint: cfg.endpoint || '',
|
||||
api_key: '',
|
||||
model_default: cfg.model_default || '',
|
||||
});
|
||||
setShowForm(true);
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!form.name || !form.endpoint) {
|
||||
sw.emit('toast', { message: 'Name and endpoint are required', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!editId && !form.api_key) {
|
||||
sw.emit('toast', { message: 'API key is required', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editId) {
|
||||
const patch = { name: form.name, provider: form.provider, endpoint: form.endpoint, model_default: form.model_default };
|
||||
if (form.api_key) patch.api_key = form.api_key;
|
||||
await sw.api.providers.update(editId, patch);
|
||||
sw.emit('toast', { message: 'Provider updated', variant: 'success' });
|
||||
} else {
|
||||
const body = { name: form.name, provider: form.provider, endpoint: form.endpoint, api_key: form.api_key, model_default: form.model_default };
|
||||
const result = await sw.api.providers.create(body);
|
||||
const count = result?.models_fetched || 0;
|
||||
sw.emit('toast', {
|
||||
message: result?.warning || `Provider added \u2014 ${count} model${count !== 1 ? 's' : ''} synced`,
|
||||
variant: result?.warning ? 'warning' : 'success',
|
||||
});
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, editId, load]);
|
||||
|
||||
const del = useCallback(async (prov) => {
|
||||
const ok = await sw.confirm(`Remove provider "${prov.name}"?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.providers.del(prov.id);
|
||||
sw.emit('toast', { message: 'Provider removed', variant: 'success' });
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const sync = useCallback(async (prov) => {
|
||||
sw.emit('toast', { message: `Fetching models for ${prov.name}\u2026`, variant: 'info' });
|
||||
try {
|
||||
const result = await sw.api.providers.fetchModels(prov.id);
|
||||
const total = result?.total || 0;
|
||||
sw.emit('toast', { message: `${prov.name}: ${total} model${total !== 1 ? 's' : ''} synced`, variant: 'success' });
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setField = useCallback((key, val) => {
|
||||
setForm(f => ({ ...f, [key]: val }));
|
||||
}, []);
|
||||
|
||||
if (providers === null) {
|
||||
return html`<div class="settings-placeholder">Loading providers\u2026</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn-md btn-primary" onClick=${openAdd}>+ Add Provider</button>
|
||||
</div>
|
||||
|
||||
${showForm && html`
|
||||
<div class="settings-section" style="margin-bottom:16px;">
|
||||
<h3>${editId ? 'Edit Provider' : 'Add Provider'}</h3>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" value=${form.name}
|
||||
onInput=${e => setField('name', e.target.value)} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Provider Type</label>
|
||||
<select value=${form.provider}
|
||||
onChange=${e => setField('provider', e.target.value)}>
|
||||
<option value="openai">OpenAI-compatible</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="venice">Venice AI</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Endpoint URL</label>
|
||||
<input type="text" placeholder="https://api.openai.com/v1"
|
||||
value=${form.endpoint}
|
||||
onInput=${e => setField('endpoint', e.target.value)} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Key${editId ? ' (leave blank to keep current)' : ''}</label>
|
||||
<input type="password" placeholder="sk-..."
|
||||
value=${form.api_key}
|
||||
onInput=${e => setField('api_key', e.target.value)} />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||
<button class="btn-md btn-primary" disabled=${saving}
|
||||
onClick=${save}>
|
||||
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Add'}
|
||||
</button>
|
||||
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${!providers.length && !showForm && html`
|
||||
<div class="empty-hint">No providers configured.</div>
|
||||
`}
|
||||
|
||||
${providers.length > 0 && html`
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Provider</th><th>Type</th><th>Endpoint</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
${providers.map(p => html`
|
||||
<tr>
|
||||
<td style="font-weight:500;font-size:13px;">${esc(p.name)}</td>
|
||||
<td style="font-size:12px;color:var(--text-2);">${esc(p.provider)}</td>
|
||||
<td style="font-size:12px;color:var(--text-3);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||
${esc(p.endpoint)}
|
||||
</td>
|
||||
<td class="admin-actions-cell" style="white-space:nowrap;">
|
||||
<button class="icon-btn" title="Sync models" onClick=${() => sync(p)}>
|
||||
\u{1F504}
|
||||
</button>
|
||||
<button class="icon-btn" title="Edit" onClick=${() => openEdit(p)}>
|
||||
\u{270F}\u{FE0F}
|
||||
</button>
|
||||
<button class="icon-btn icon-btn-danger" title="Delete"
|
||||
onClick=${() => del(p)}>
|
||||
\u{1F5D1}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user