Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
200 lines
8.3 KiB
JavaScript
200 lines
8.3 KiB
JavaScript
/**
|
|
* 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>
|
|
`}
|
|
`;
|
|
}
|