Changeset 0.4.3 (#32)

This commit is contained in:
2026-02-18 17:47:21 +00:00
parent 2fc4f6980c
commit c98eb80950
14 changed files with 1134 additions and 17 deletions

View File

@@ -42,6 +42,7 @@ body {
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
flex-shrink: 0;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
@@ -87,6 +88,7 @@ body {
.main-container {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
@@ -170,12 +172,14 @@ body {
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1rem 2rem;
min-height: 0;
}
/* Messages */
@@ -386,6 +390,7 @@ body {
background: var(--bg-secondary);
border-top: 1px solid var(--border);
padding: 1rem 2rem;
flex-shrink: 0;
}
.input-container { max-width: 800px; margin: 0 auto; }
@@ -1199,3 +1204,211 @@ body {
#profileChangePwForm .form-group {
margin-bottom: 0.5rem;
}
/* ── API Providers List ──────────────────── */
.provider-list {
margin-bottom: 0.5rem;
}
.provider-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 0.4rem;
background: var(--bg-primary);
}
.provider-info {
flex: 1;
min-width: 0;
}
.provider-name {
font-weight: 600;
font-size: 0.9rem;
display: block;
}
.provider-meta {
font-size: 0.75rem;
color: var(--text-secondary);
}
.provider-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.provider-empty {
padding: 0.75rem;
text-align: center;
color: var(--text-secondary);
font-size: 0.85rem;
border: 1px dashed var(--border);
border-radius: 6px;
margin-bottom: 0.5rem;
}
.provider-add-toggle {
margin-bottom: 0.5rem;
}
.provider-add-form {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.provider-add-form .form-group {
margin-bottom: 0.5rem;
}
.provider-add-form .form-group:last-of-type {
margin-bottom: 0.75rem;
}
.admin-hint {
font-size: 0.8rem;
color: var(--text-secondary);
margin: 0 0 0.75rem 0;
}
/* ── Admin Models Tab ────────────────────── */
.admin-models-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.admin-models-header .admin-hint {
flex: 1;
margin: 0;
}
.admin-model-group {
margin-bottom: 1rem;
}
.admin-model-group-header {
font-size: 0.8rem;
font-weight: 600;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.25rem 0;
border-bottom: 1px solid var(--border);
margin-bottom: 0.25rem;
}
.admin-model-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
}
.admin-model-row:last-child {
border-bottom: none;
}
.admin-model-toggle {
flex-shrink: 0;
}
.toggle-label-compact input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.admin-model-info {
flex: 1;
min-width: 0;
}
.admin-model-name {
font-weight: 500;
font-size: 0.85rem;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-model-id {
font-size: 0.7rem;
color: var(--text-secondary);
font-family: monospace;
}
.admin-model-caps {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.cap-badge {
font-size: 0.65rem;
padding: 0.1rem 0.35rem;
border-radius: 3px;
border: 1px solid var(--border);
cursor: pointer;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
background: none;
transition: all 0.15s;
}
.cap-active {
background: var(--accent);
color: #000;
border-color: var(--accent);
}
.cap-inactive {
color: var(--text-secondary);
opacity: 0.5;
}
.cap-badge:hover {
opacity: 1;
}
.admin-model-actions {
flex-shrink: 0;
}
/* ── Settings Mode Badge ─────────────────── */
.settings-mode-badge {
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.8rem;
margin-bottom: 1rem;
font-weight: 500;
}
.mode-managed {
background: #10b98115;
border: 1px solid #10b98133;
color: #34d399;
}
.mode-unmanaged {
background: #f59e0b15;
border: 1px solid #f59e0b33;
color: #fbbf24;
}

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Switchboard</title>
<link rel="stylesheet" href="css/styles.css?v=0.3.4">
<link rel="stylesheet" href="css/styles.css?v=0.4.2">
</head>
<body>
<div id="appContainer" class="app-hidden" style="display:none">
@@ -117,6 +117,9 @@
<button class="modal-close" id="closeModalBtn"></button>
</div>
<div class="modal-body">
<!-- Mode indicator -->
<div class="settings-mode-badge" id="settingsModeBadge"></div>
<!-- Profile (managed mode only) -->
<div class="settings-section" id="profileSection" style="display:none">
<h3 class="settings-section-title">Profile</h3>
@@ -160,6 +163,45 @@
</div>
</div> <!-- /unmanagedSettings -->
<!-- API Providers (managed mode only) -->
<div id="managedProviders" style="display:none">
<h3 class="settings-section-title">API Providers</h3>
<div id="providerList" class="provider-list"></div>
<div class="provider-add-toggle">
<button class="btn btn-secondary btn-small" id="providerShowAddBtn">+ Add Provider</button>
</div>
<div class="provider-add-form" id="providerAddForm" style="display:none">
<div class="form-group">
<label for="providerName">Name</label>
<input type="text" id="providerName" placeholder="e.g. My OpenAI Key">
</div>
<div class="form-group">
<label for="providerType">Provider</label>
<select id="providerType">
<option value="openai">OpenAI-compatible</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="form-group">
<label for="providerEndpoint">Endpoint</label>
<input type="text" id="providerEndpoint" placeholder="https://api.openai.com/v1">
</div>
<div class="form-group">
<label for="providerApiKey">API Key</label>
<input type="password" id="providerApiKey" placeholder="sk-...">
</div>
<div class="form-group">
<label for="providerDefaultModel">Default Model (optional)</label>
<input type="text" id="providerDefaultModel" placeholder="gpt-4o">
</div>
<div class="admin-form-row">
<button class="btn btn-primary btn-small" id="providerCreateBtn">Save</button>
<button class="btn btn-secondary btn-small" id="providerCancelBtn">Cancel</button>
</div>
</div>
<hr class="settings-divider">
</div>
<div class="form-group">
<label for="model">Default Model</label>
<div style="display: flex; gap: 0.5rem;">
@@ -243,6 +285,8 @@
<div class="modal-body">
<div class="admin-tabs">
<button class="admin-tab active" data-tab="users">Users</button>
<button class="admin-tab" data-tab="models">Models</button>
<button class="admin-tab" data-tab="providers">Providers</button>
<button class="admin-tab" data-tab="settings">Settings</button>
<button class="admin-tab" data-tab="stats">Stats</button>
</div>
@@ -273,7 +317,7 @@
<div id="adminUserList" class="admin-user-list">Loading...</div>
</div>
<!-- Reset Password Dialog (inline) -->
<!-- Reset Password Dialog (inline overlay) -->
<div class="admin-reset-dialog" id="adminResetDialog" style="display:none">
<div class="admin-reset-card">
<h3>Reset Password</h3>
@@ -286,6 +330,55 @@
</div>
</div>
<!-- Models Tab -->
<div class="admin-tab-content" id="adminModelsTab" style="display:none">
<div class="admin-models-header">
<p class="admin-hint">Manage which models are available to users. Fetch from configured providers, then enable/disable and tag capabilities.</p>
<button class="btn btn-primary btn-small" id="adminFetchModelsBtn">🔄 Fetch Models</button>
</div>
<div id="adminModelList" class="admin-model-list">
<div class="admin-empty">No models configured. Add a provider first, then fetch models.</div>
</div>
</div>
<!-- Providers Tab -->
<div class="admin-tab-content" id="adminProvidersTab" style="display:none">
<p class="admin-hint">Global providers are available to all users. Users can also add their own.</p>
<div id="adminProviderList" class="provider-list"></div>
<div class="provider-add-toggle">
<button class="btn btn-secondary btn-small" id="adminProviderShowAddBtn">+ Add Global Provider</button>
</div>
<div class="provider-add-form" id="adminProviderAddForm" style="display:none">
<div class="form-group">
<label for="adminProviderName">Name</label>
<input type="text" id="adminProviderName" placeholder="e.g. Shared OpenAI">
</div>
<div class="form-group">
<label for="adminProviderType">Provider</label>
<select id="adminProviderType">
<option value="openai">OpenAI-compatible</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="form-group">
<label for="adminProviderEndpoint">Endpoint</label>
<input type="text" id="adminProviderEndpoint" placeholder="https://api.openai.com/v1">
</div>
<div class="form-group">
<label for="adminProviderApiKey">API Key</label>
<input type="password" id="adminProviderApiKey" placeholder="sk-...">
</div>
<div class="form-group">
<label for="adminProviderDefaultModel">Default Model (optional)</label>
<input type="text" id="adminProviderDefaultModel" placeholder="gpt-4o">
</div>
<div class="admin-form-row">
<button class="btn btn-primary btn-small" id="adminProviderCreateBtn">Save</button>
<button class="btn btn-secondary btn-small" id="adminProviderCancelBtn">Cancel</button>
</div>
</div>
</div>
<!-- Settings Tab -->
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
<div class="form-group">
@@ -365,12 +458,12 @@
<div class="toast-container" id="toastContainer"></div>
<script src="js/storage.js?v=0.3.4"></script>
<script src="js/backend.js?v=0.3.4"></script>
<script src="js/state.js?v=0.3.4"></script>
<script src="js/api.js?v=0.3.4"></script>
<script src="js/ui.js?v=0.3.4"></script>
<script src="js/admin.js?v=0.3.4"></script>
<script src="js/app.js?v=0.3.4"></script>
<script src="js/storage.js?v=0.4.2"></script>
<script src="js/backend.js?v=0.4.2"></script>
<script src="js/state.js?v=0.4.2"></script>
<script src="js/api.js?v=0.4.2"></script>
<script src="js/ui.js?v=0.4.2"></script>
<script src="js/admin.js?v=0.4.2"></script>
<script src="js/app.js?v=0.4.2"></script>
</body>
</html>

View File

@@ -40,6 +40,21 @@ function initAdminListeners() {
document.getElementById('adminResetCancelBtn').addEventListener('click', closeResetDialog);
document.getElementById('adminResetConfirmBtn').addEventListener('click', handleResetPassword);
// Admin providers
document.getElementById('adminProviderShowAddBtn').addEventListener('click', () => {
document.getElementById('adminProviderAddForm').style.display = '';
document.getElementById('adminProviderShowAddBtn').style.display = 'none';
});
document.getElementById('adminProviderCancelBtn').addEventListener('click', () => {
document.getElementById('adminProviderAddForm').style.display = 'none';
document.getElementById('adminProviderShowAddBtn').style.display = '';
clearAdminProviderForm();
});
document.getElementById('adminProviderCreateBtn').addEventListener('click', handleCreateAdminProvider);
// Admin models
document.getElementById('adminFetchModelsBtn').addEventListener('click', handleAdminFetchModels);
document.getElementById('adminResetNewPw').addEventListener('keydown', function(e) {
if (e.key === 'Enter') handleResetPassword();
});
@@ -50,10 +65,14 @@ function switchAdminTab(tab) {
t.classList.toggle('active', t.dataset.tab === tab);
});
document.getElementById('adminUsersTab').style.display = tab === 'users' ? '' : 'none';
document.getElementById('adminModelsTab').style.display = tab === 'models' ? '' : 'none';
document.getElementById('adminProvidersTab').style.display = tab === 'providers' ? '' : 'none';
document.getElementById('adminSettingsTab').style.display = tab === 'settings' ? '' : 'none';
document.getElementById('adminStatsTab').style.display = tab === 'stats' ? '' : 'none';
document.getElementById('adminResetDialog').style.display = 'none';
if (tab === 'models') loadAdminModels();
if (tab === 'providers') loadAdminProviders();
if (tab === 'settings') loadAdminSettings();
if (tab === 'stats') loadAdminStats();
}
@@ -271,3 +290,198 @@ function escapeHtml(str) {
div.textContent = str;
return div.innerHTML;
}
// ── Admin Models ────────────────────────────
const CAPS = ['tool', 'thinking', 'vision', 'code'];
async function loadAdminModels() {
const container = document.getElementById('adminModelList');
container.innerHTML = '<div class="admin-loading">Loading models...</div>';
try {
const data = await Backend.adminListModels();
const models = data.models || [];
if (models.length === 0) {
container.innerHTML = '<div class="admin-empty">No models configured. Add a provider first, then click "Fetch Models".</div>';
return;
}
// Group by provider
const grouped = {};
for (const m of models) {
const key = m.provider_name || 'Unknown';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(m);
}
let html = '';
for (const [provider, provModels] of Object.entries(grouped)) {
html += '<div class="admin-model-group">';
html += '<div class="admin-model-group-header">' + escapeHtml(provider) + '</div>';
for (const m of provModels) {
const caps = m.capabilities || {};
const name = m.display_name || m.model_id;
html += '<div class="admin-model-row">';
html += ' <div class="admin-model-toggle">';
html += ' <label class="toggle-label-compact">';
html += ' <input type="checkbox" ' + (m.is_enabled ? 'checked' : '') +
' onchange="toggleModelEnabled(\'' + m.id + '\', this.checked)">';
html += ' </label>';
html += ' </div>';
html += ' <div class="admin-model-info">';
html += ' <span class="admin-model-name">' + escapeHtml(name) + '</span>';
html += ' <span class="admin-model-id">' + escapeHtml(m.model_id) + '</span>';
html += ' </div>';
html += ' <div class="admin-model-caps">';
for (const cap of CAPS) {
const active = caps[cap] === true;
html += ' <button class="cap-badge ' + (active ? 'cap-active' : 'cap-inactive') + '"' +
' onclick="toggleModelCap(\'' + m.id + '\', \'' + cap + '\', ' + !active + ', \'' + escapeHtml(JSON.stringify(caps)) + '\')"' +
' title="' + cap + '">' + cap + '</button>';
}
html += ' </div>';
html += ' <div class="admin-model-actions">';
html += ' <button class="btn btn-small btn-danger" onclick="deleteAdminModel(\'' + m.id + '\', \'' + escapeHtml(m.model_id) + '\')">✕</button>';
html += ' </div>';
html += '</div>';
}
html += '</div>';
}
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div class="admin-error">Failed to load models: ' + e.message + '</div>';
}
}
async function handleAdminFetchModels() {
const btn = document.getElementById('adminFetchModelsBtn');
btn.disabled = true;
btn.textContent = '⏳ Fetching...';
try {
const data = await Backend.adminFetchModels();
const added = data.total_added || 0;
const results = data.results || [];
const errors = results.filter(r => r.error);
if (errors.length > 0) {
showToast('⚠️ Fetched with ' + errors.length + ' error(s). ' + added + ' new models.', 'warning');
} else {
showToast('✅ Fetched ' + added + ' new models', 'success');
}
loadAdminModels();
} catch (e) {
showToast('❌ ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = '🔄 Fetch Models';
}
}
async function toggleModelEnabled(modelId, enabled) {
try {
await Backend.adminUpdateModel(modelId, { is_enabled: enabled });
} catch (e) {
showToast('❌ ' + e.message, 'error');
loadAdminModels();
}
}
async function toggleModelCap(modelId, cap, value, capsJson) {
try {
const caps = JSON.parse(capsJson);
caps[cap] = value;
await Backend.adminUpdateModel(modelId, { capabilities: caps });
loadAdminModels();
} catch (e) {
showToast('❌ ' + e.message, 'error');
loadAdminModels();
}
}
async function deleteAdminModel(modelId, modelName) {
if (!confirm('Remove model "' + modelName + '"? It can be re-fetched later.')) return;
try {
await Backend.adminDeleteModel(modelId);
showToast('✅ Model removed', 'success');
loadAdminModels();
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
async function loadAdminProviders() {
const container = document.getElementById('adminProviderList');
container.innerHTML = '<div class="admin-loading">Loading...</div>';
try {
const data = await Backend.adminListGlobalConfigs();
const configs = data.configs || [];
if (configs.length === 0) {
container.innerHTML = '<div class="provider-empty">No global providers. Users must add their own.</div>';
return;
}
container.innerHTML = configs.map(c => `
<div class="provider-row">
<div class="provider-info">
<span class="provider-name">${escapeHtml(c.name)}</span>
<span class="provider-meta">${c.provider} · ${c.model_default || 'no default'}</span>
</div>
<div class="provider-actions">
<span class="admin-badge admin-badge-active">${c.has_key ? '🔑' : '⚠️'}</span>
<button class="btn btn-small btn-danger" onclick="deleteAdminProvider('${c.id}', '${escapeHtml(c.name)}')">Remove</button>
</div>
</div>
`).join('');
} catch (e) {
container.innerHTML = '<div class="admin-error">Failed to load providers: ' + e.message + '</div>';
}
}
function clearAdminProviderForm() {
document.getElementById('adminProviderName').value = '';
document.getElementById('adminProviderType').value = 'openai';
document.getElementById('adminProviderEndpoint').value = '';
document.getElementById('adminProviderApiKey').value = '';
document.getElementById('adminProviderDefaultModel').value = '';
}
async function handleCreateAdminProvider() {
const name = document.getElementById('adminProviderName').value.trim();
const provider = document.getElementById('adminProviderType').value;
const endpoint = document.getElementById('adminProviderEndpoint').value.trim();
const apiKey = document.getElementById('adminProviderApiKey').value.trim();
const modelDefault = document.getElementById('adminProviderDefaultModel').value.trim();
if (!name || !endpoint || !apiKey) {
showToast('⚠️ Name, endpoint, and API key are required', 'warning');
return;
}
try {
await Backend.adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault || null);
showToast('✅ Global provider added', 'success');
clearAdminProviderForm();
document.getElementById('adminProviderAddForm').style.display = 'none';
document.getElementById('adminProviderShowAddBtn').style.display = '';
loadAdminProviders();
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
async function deleteAdminProvider(configId, name) {
if (!confirm('Remove global provider "' + name + '"?')) return;
try {
await Backend.adminDeleteGlobalConfig(configId);
showToast('✅ Global provider removed', 'success');
loadAdminProviders();
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}

View File

@@ -16,13 +16,22 @@ async function fetchModels(showInModal = true) {
let models = [];
if (Backend.isManaged) {
// Managed mode: fetch from backend aggregated models endpoint
const data = await Backend.listAllModels();
// Managed mode: fetch admin-curated enabled models
const data = await Backend.listEnabledModels();
models = (data.models || []).map(m => ({
id: m.id || m.name,
owned_by: m.owned_by || m.provider || null,
id: m.model_id || m.id,
owned_by: m.provider_name || m.provider || null,
config_id: m.config_id || null
}));
// Fallback: if no curated models, try raw aggregation
if (models.length === 0) {
const raw = await Backend.listAllModels();
models = (raw.models || []).map(m => ({
id: m.id || m.name,
owned_by: m.owned_by || m.provider || null,
config_id: m.config_id || null
}));
}
} else {
// Unmanaged mode: direct provider call
const endpoint = showInModal

View File

@@ -39,12 +39,19 @@ async function init() {
// Already authed or no backend → go straight to app
hideSplashGate();
await loadSettingsFromBackend();
await initApp();
}
async function initApp() {
await loadChats();
initializeEventListeners();
// In managed mode, fetch models from backend (ignore localStorage cache)
if (Backend.isManaged) {
fetchModels(false); // async, non-blocking
}
updateQuickModelSelector();
renderChatHistory();
updateConnectionStatus();
@@ -80,8 +87,9 @@ function hideSplashGate() {
async function authSuccess() {
hideSplashGate();
await loadSettingsFromBackend();
await initApp();
showToast(`✅ Welcome, ${Backend.user?.username || 'user'}!`, 'success');
showToast(`✅ Welcome, ${Backend.user?.display_name || Backend.user?.username || 'user'}!`, 'success');
}
function initializeEventListeners() {
@@ -99,6 +107,12 @@ function initializeEventListeners() {
});
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
// Providers (managed mode)
document.getElementById('providerShowAddBtn').addEventListener('click', showProviderForm);
document.getElementById('providerCancelBtn').addEventListener('click', hideProviderForm);
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
document.getElementById('providerType').addEventListener('change', updateProviderEndpointHint);
// Chat controls
document.getElementById('newChatBtn').addEventListener('click', newChat);
document.getElementById('sendBtn').addEventListener('click', sendMessage);

View File

@@ -294,6 +294,57 @@ const Backend = {
return this._authedFetch('/api/v1/admin/stats');
},
// Admin Global Configs
async adminListGlobalConfigs() {
return this._authedFetch('/api/v1/admin/configs');
},
async adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault) {
return this._authedFetch('/api/v1/admin/configs', {
method: 'POST',
body: JSON.stringify({
name, provider, endpoint,
api_key: apiKey,
model_default: modelDefault
})
});
},
async adminDeleteGlobalConfig(configId) {
return this._authedFetch(`/api/v1/admin/configs/${configId}`, {
method: 'DELETE'
});
},
// Admin Model Configs
async adminListModels() {
return this._authedFetch('/api/v1/admin/models');
},
async adminFetchModels() {
return this._authedFetch('/api/v1/admin/models/fetch', {
method: 'POST'
});
},
async adminUpdateModel(modelId, updates) {
return this._authedFetch(`/api/v1/admin/models/${modelId}`, {
method: 'PUT',
body: JSON.stringify(updates)
});
},
async adminDeleteModel(modelId) {
return this._authedFetch(`/api/v1/admin/models/${modelId}`, {
method: 'DELETE'
});
},
// Enabled models (for quick selector in managed mode)
async listEnabledModels() {
return this._authedFetch('/api/v1/models/enabled');
},
// ── Models ──────────────────────────────
async listAllModels() {

View File

@@ -26,7 +26,7 @@ const State = {
abortController: null
};
// ── Settings (always localStorage) ──────────
// ── Settings (localStorage + backend sync) ──
function loadSettings() {
const saved = Storage.get('chatSwitchboard_settings');
@@ -37,6 +37,47 @@ function loadSettings() {
function saveSettings() {
Storage.set('chatSwitchboard_settings', State.settings);
// Fire-and-forget sync to backend
syncSettingsToBackend();
}
async function syncSettingsToBackend() {
if (!Backend.isManaged) return;
try {
// Only sync preferences, not apiEndpoint/apiKey (those live in api_configs)
const prefs = {
model: State.settings.model,
stream: State.settings.stream,
saveHistory: State.settings.saveHistory,
showThinking: State.settings.showThinking,
systemPrompt: State.settings.systemPrompt,
maxTokens: State.settings.maxTokens,
temperature: State.settings.temperature,
topP: State.settings.topP,
presencePenalty: State.settings.presencePenalty
};
await Backend.updateSettings(prefs);
} catch (e) {
console.warn('Failed to sync settings to backend:', e);
}
}
async function loadSettingsFromBackend() {
if (!Backend.isManaged) return;
try {
const remote = await Backend.getSettings();
if (remote && Object.keys(remote).length > 0) {
// Merge remote prefs over local, but keep apiEndpoint/apiKey local
const localEndpoint = State.settings.apiEndpoint;
const localKey = State.settings.apiKey;
State.settings = { ...State.settings, ...remote };
State.settings.apiEndpoint = localEndpoint;
State.settings.apiKey = localKey;
Storage.set('chatSwitchboard_settings', State.settings);
}
} catch (e) {
console.warn('Failed to load settings from backend:', e);
}
}
// ── Models (always localStorage) ────────────

View File

@@ -89,22 +89,27 @@ function updateQuickModelSelector() {
function openSettings() {
updateSettingsUI();
updateProfileSection();
updateProviderSection();
document.getElementById('settingsModal').classList.add('active');
}
function updateProfileSection() {
const profileSection = document.getElementById('profileSection');
const unmanagedSettings = document.getElementById('unmanagedSettings');
const modeBadge = document.getElementById('settingsModeBadge');
if (Backend.isManaged) {
profileSection.style.display = '';
unmanagedSettings.style.display = 'none';
modeBadge.textContent = '🔗 Managed Mode — API keys are configured by your admin or in Providers below';
modeBadge.className = 'settings-mode-badge mode-managed';
loadProfileIntoSettings();
} else {
profileSection.style.display = 'none';
unmanagedSettings.style.display = '';
modeBadge.textContent = '📱 Offline Mode — Configure your own API endpoint and key';
modeBadge.className = 'settings-mode-badge mode-unmanaged';
}
// Always reset password form
document.getElementById('profileChangePwForm').style.display = 'none';
}
@@ -170,6 +175,121 @@ async function handleChangePassword() {
}
}
// ── API Providers (managed mode) ────────────
function updateProviderSection() {
const managed = document.getElementById('managedProviders');
if (Backend.isManaged) {
managed.style.display = '';
loadProviderList();
} else {
managed.style.display = 'none';
}
}
async function loadProviderList() {
const container = document.getElementById('providerList');
container.innerHTML = '<div class="admin-loading">Loading...</div>';
try {
const data = await Backend.listConfigs();
const configs = data.configs || data.data || data || [];
const list = Array.isArray(configs) ? configs : [];
if (list.length === 0) {
container.innerHTML = '<div class="provider-empty">No providers configured. Add one to start chatting.</div>';
return;
}
container.innerHTML = list.map(c => `
<div class="provider-row">
<div class="provider-info">
<span class="provider-name">${escapeHtmlUI(c.name)}</span>
<span class="provider-meta">${c.provider} · ${c.model_default || 'no default'}</span>
</div>
<div class="provider-actions">
<span class="admin-badge admin-badge-active">${c.has_key ? '🔑' : '⚠️'}</span>
${c.user_id ? `<button class="btn btn-small btn-danger" onclick="deleteProvider('${c.id}', '${escapeHtmlUI(c.name)}')">Remove</button>` : '<span class="admin-badge admin-badge-moderator">global</span>'}
</div>
</div>
`).join('');
} catch (e) {
container.innerHTML = '<div class="admin-error">Failed to load providers: ' + e.message + '</div>';
}
}
function showProviderForm() {
document.getElementById('providerAddForm').style.display = '';
document.getElementById('providerShowAddBtn').style.display = 'none';
// Set default endpoint based on provider type
updateProviderEndpointHint();
}
function hideProviderForm() {
document.getElementById('providerAddForm').style.display = 'none';
document.getElementById('providerShowAddBtn').style.display = '';
clearProviderForm();
}
function clearProviderForm() {
document.getElementById('providerName').value = '';
document.getElementById('providerType').value = 'openai';
document.getElementById('providerEndpoint').value = '';
document.getElementById('providerApiKey').value = '';
document.getElementById('providerDefaultModel').value = '';
}
function updateProviderEndpointHint() {
const type = document.getElementById('providerType').value;
const endpoint = document.getElementById('providerEndpoint');
if (!endpoint.value) {
endpoint.placeholder = type === 'anthropic'
? 'https://api.anthropic.com'
: 'https://api.openai.com/v1';
}
}
async function handleCreateProvider() {
const name = document.getElementById('providerName').value.trim();
const provider = document.getElementById('providerType').value;
const endpoint = document.getElementById('providerEndpoint').value.trim();
const apiKey = document.getElementById('providerApiKey').value.trim();
const modelDefault = document.getElementById('providerDefaultModel').value.trim();
if (!name || !endpoint || !apiKey) {
showToast('⚠️ Name, endpoint, and API key are required', 'warning');
return;
}
try {
await Backend.createConfig(name, provider, endpoint, apiKey, modelDefault || null);
showToast('✅ Provider added', 'success');
hideProviderForm();
loadProviderList();
fetchModels(false); // Refresh model list
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
async function deleteProvider(configId, name) {
if (!confirm('Remove provider "' + name + '"?')) return;
try {
await Backend.deleteConfig(configId);
showToast('✅ Provider removed', 'success');
loadProviderList();
fetchModels(false);
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
function escapeHtmlUI(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
function closeSettings() {
document.getElementById('settingsModal').classList.remove('active');
}