Changeset 0.7.3 (#41)
This commit is contained in:
@@ -276,6 +276,10 @@ a:hover { text-decoration: underline; }
|
||||
background: var(--bg-raised); display: flex; align-items: center;
|
||||
justify-content: center; font-size: 13px; font-weight: 600;
|
||||
color: var(--accent); flex-shrink: 0; position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.user-avatar-img {
|
||||
width: 100%; height: 100%; object-fit: cover; border-radius: 50%;
|
||||
}
|
||||
.avatar-bug {
|
||||
position: absolute; bottom: -3px; right: -3px;
|
||||
@@ -369,6 +373,7 @@ a:hover { text-decoration: underline; }
|
||||
.model-dropdown-item.selected { background: var(--bg-hover); color: var(--accent); }
|
||||
.model-dropdown-item .item-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||
.model-dropdown-item .item-provider { margin-left: auto; font-size: 10px; color: var(--text-3); }
|
||||
.dropdown-avatar { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||
|
||||
.model-caps {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
@@ -408,6 +413,10 @@ a:hover { text-decoration: underline; }
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; flex-shrink: 0; margin-top: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.msg-avatar-img {
|
||||
width: 100%; height: 100%; object-fit: cover; border-radius: 50%;
|
||||
}
|
||||
.message.user .msg-avatar { background: var(--accent-dim); }
|
||||
.message.assistant .msg-avatar { background: var(--bg-raised); }
|
||||
@@ -725,8 +734,10 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.hero-content { position: relative; z-index: 1; max-width: 520px; }
|
||||
.hero-logo-row { display: flex; align-items: center; gap: 14px; margin-bottom: 1.5rem; }
|
||||
.hero-logo-mark { width: 48px; height: 48px; flex-shrink: 0; }
|
||||
.hero-logo-img { width: 48px; height: 48px; object-fit: contain; border-radius: 8px; }
|
||||
.hero-wordmark { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; }
|
||||
.hero-wordmark span { color: var(--accent); }
|
||||
.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; vertical-align: middle; }
|
||||
.hero-headline {
|
||||
font-size: 2.4rem; font-weight: 700; line-height: 1.15;
|
||||
letter-spacing: -0.03em; margin-bottom: 1rem;
|
||||
@@ -808,6 +819,7 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.splash-hero { padding: 1.5rem 1.25rem 1rem; }
|
||||
.hero-logo-row { gap: 10px; margin-bottom: 0.75rem; }
|
||||
.hero-logo-mark { width: 40px; height: 40px; }
|
||||
.hero-logo-img { width: 40px; height: 40px; }
|
||||
.hero-wordmark { font-size: 1.3rem; }
|
||||
.hero-headline { font-size: 1.35rem; margin-bottom: 0.5rem; }
|
||||
.hero-sub { font-size: 0.85rem; margin-bottom: 1rem; line-height: 1.5; }
|
||||
@@ -891,6 +903,17 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.settings-section { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); }
|
||||
.settings-section:last-child { border-bottom: none; margin-bottom: 0; }
|
||||
.settings-section h3 { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.avatar-upload-row { display: flex; align-items: center; gap: 14px; margin-bottom: 1rem; }
|
||||
.avatar-preview {
|
||||
width: 56px; height: 56px; border-radius: 50%; background: var(--bg-raised);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px; font-weight: 600; color: var(--accent); flex-shrink: 0;
|
||||
overflow: hidden; border: 2px solid var(--border);
|
||||
}
|
||||
.avatar-preview img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.avatar-actions { display: flex; flex-direction: column; gap: 4px; }
|
||||
.avatar-actions .form-hint { font-size: 11px; margin-left: 0; }
|
||||
.avatar-preview-sm { width: 42px; height: 42px; font-size: 18px; }
|
||||
|
||||
/* Modal tab bars — shared horizontal scroll with arrow navigation */
|
||||
.modal-tabs-wrap {
|
||||
@@ -1001,6 +1024,8 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
|
||||
.admin-preset-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
.admin-preset-row .preset-info { flex: 1; min-width: 0; }
|
||||
.preset-row-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; vertical-align: middle; margin-right: 6px; }
|
||||
.preset-row-icon { margin-right: 4px; }
|
||||
.admin-preset-row .preset-meta { font-size: 12px; color: var(--text-3); margin-top: 2px; }
|
||||
.admin-preset-row .preset-desc { font-size: 12px; color: var(--text-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.admin-preset-row .preset-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
<base href="%%BASE_HREF%%">
|
||||
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
|
||||
<script>window.__VERSION__ = '%%APP_VERSION%%'; if (window.__VERSION__.includes('%%')) window.__VERSION__ = 'dev';</script>
|
||||
<script>try { window.__BRANDING__ = %%BRANDING_JSON%%; } catch(e) { window.__BRANDING__ = {}; }</script>
|
||||
<title>Chat Switchboard</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
||||
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
|
||||
<link rel="stylesheet" href="branding/custom.css" onerror="this.remove()">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -25,9 +27,9 @@
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-top">
|
||||
<button class="sb-brand" id="sidebarToggle" title="Toggle sidebar">
|
||||
<span class="brand-logo">🔀</span>
|
||||
<span class="brand-logo" id="brandSidebarLogo">🔀</span>
|
||||
<svg class="brand-collapse" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
|
||||
<span class="sb-label brand-text">Chat Switchboard</span>
|
||||
<span class="sb-label brand-text" id="brandSidebarText">Chat Switchboard</span>
|
||||
</button>
|
||||
<div class="split-btn" style="position:relative">
|
||||
<button class="split-btn-main" id="newChatBtn" title="New Chat">
|
||||
@@ -147,7 +149,8 @@
|
||||
<div class="splash-hero">
|
||||
<div class="hero-content">
|
||||
<div class="hero-logo-row">
|
||||
<svg class="hero-logo-mark" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<div class="hero-logo-mark" id="brandLogo">
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="#1a1a2e"/>
|
||||
<path d="M12 48 L36 16" stroke="#6c9fff" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
||||
<polygon points="42,12 34,14 38,22" fill="#6c9fff"/>
|
||||
@@ -155,11 +158,12 @@
|
||||
<path d="M30 38 L36 48" stroke="#a78bfa" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
||||
<polygon points="42,52 34,50 38,42" fill="#a78bfa"/>
|
||||
</svg>
|
||||
<div class="hero-wordmark">Chat <span>Switchboard</span></div>
|
||||
</div>
|
||||
<div class="hero-wordmark" id="brandWordmark">Chat <span>Switchboard</span></div>
|
||||
</div>
|
||||
<h1 class="hero-headline">One interface.<br>Every AI model.</h1>
|
||||
<p class="hero-sub">Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.</p>
|
||||
<div class="hero-features">
|
||||
<h1 class="hero-headline" id="brandHeadline">One interface.<br>Every AI model.</h1>
|
||||
<p class="hero-sub" id="brandTagline">Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.</p>
|
||||
<div class="hero-features" id="brandPills">
|
||||
<div class="hero-pill accent"><span class="pill-icon">⚡</span> Streaming responses</div>
|
||||
<div class="hero-pill purple"><span class="pill-icon">🔀</span> Multi-provider routing</div>
|
||||
<div class="hero-pill"><span class="pill-icon">🔑</span> Bring your own API keys</div>
|
||||
@@ -196,7 +200,7 @@
|
||||
<button class="btn-primary btn-full" id="authRegisterBtn" data-label="Create Account" onclick="handleRegister()" style="display:none">Create Account</button>
|
||||
</div>
|
||||
<div class="auth-footer">
|
||||
<p>Self-hosted AI chat · Your keys, your data</p>
|
||||
<p id="brandAuthFooter">Self-hosted AI chat · Your keys, your data</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,6 +221,17 @@
|
||||
<div class="settings-tab-content" id="settingsGeneralTab">
|
||||
<section class="settings-section">
|
||||
<h3>Profile</h3>
|
||||
<div class="avatar-upload-row">
|
||||
<div class="avatar-preview" id="avatarPreview">
|
||||
<span id="avatarPreviewLetter">?</span>
|
||||
</div>
|
||||
<div class="avatar-actions">
|
||||
<button class="btn-small" id="avatarUploadBtn">Upload Photo</button>
|
||||
<button class="btn-small btn-danger" id="avatarRemoveBtn" style="display:none">Remove</button>
|
||||
<input type="file" id="avatarFileInput" accept="image/png,image/jpeg,image/gif" style="display:none">
|
||||
<div class="form-hint">Auto-resized to 128×128</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Display Name</label><input type="text" id="profileDisplayName"></div>
|
||||
<div class="form-group"><label>Email</label><input type="email" id="profileEmail"></div>
|
||||
<button class="btn-small" id="profileChangePwBtn">Change Password</button>
|
||||
@@ -350,6 +365,17 @@
|
||||
<div class="form-group"><label>Name</label><input type="text" id="adminPresetName" placeholder="Code Reviewer"></div>
|
||||
<div class="form-group"><label>Icon</label><input type="text" id="adminPresetIcon" placeholder="🔍" maxlength="4" style="width:60px"></div>
|
||||
</div>
|
||||
<div class="avatar-upload-row" style="margin-bottom:0.75rem">
|
||||
<div class="avatar-preview avatar-preview-sm" id="presetAvatarPreview">
|
||||
<span id="presetAvatarPlaceholder">🤖</span>
|
||||
</div>
|
||||
<div class="avatar-actions">
|
||||
<button class="btn-small" type="button" id="presetAvatarUploadBtn">Upload Avatar</button>
|
||||
<button class="btn-small btn-danger" type="button" id="presetAvatarRemoveBtn" style="display:none">Remove</button>
|
||||
<input type="file" id="presetAvatarFileInput" accept="image/png,image/jpeg,image/gif" style="display:none">
|
||||
<div class="form-hint">Optional · 128×128 · shown in chat</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label><input type="text" id="adminPresetDesc" placeholder="Reviews code for best practices"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Base Model</label><select id="adminPresetModel"></select></div>
|
||||
|
||||
@@ -228,8 +228,19 @@ const API = {
|
||||
|
||||
// ── Profile & Settings ───────────────────
|
||||
|
||||
getProfile() { return this._get('/api/v1/profile'); },
|
||||
async getProfile() {
|
||||
const data = await this._get('/api/v1/profile');
|
||||
// Sync user object with profile data (including avatar)
|
||||
if (data && this.user) {
|
||||
this.user.avatar = data.avatar || null;
|
||||
this.user.display_name = data.display_name;
|
||||
this.saveTokens();
|
||||
}
|
||||
return data;
|
||||
},
|
||||
updateProfile(updates) { return this._put('/api/v1/profile', updates); },
|
||||
uploadAvatar(base64Image) { return this._post('/api/v1/profile/avatar', { image: base64Image }); },
|
||||
deleteAvatar() { return this._del('/api/v1/profile/avatar'); },
|
||||
changePassword(current, newPw) {
|
||||
return this._post('/api/v1/profile/password', { current_password: current, new_password: newPw });
|
||||
},
|
||||
@@ -271,6 +282,8 @@ const API = {
|
||||
adminCreatePreset(preset) { return this._post('/api/v1/admin/presets', preset); },
|
||||
adminUpdatePreset(id, updates) { return this._put(`/api/v1/admin/presets/${id}`, updates); },
|
||||
adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
|
||||
adminUploadPresetAvatar(id, base64Image) { return this._post(`/api/v1/admin/presets/${id}/avatar`, { image: base64Image }); },
|
||||
adminDeletePresetAvatar(id) { return this._del(`/api/v1/admin/presets/${id}/avatar`); },
|
||||
|
||||
// ── User Presets ─────────────────────────
|
||||
listPresets() { return this._get('/api/v1/presets'); },
|
||||
|
||||
262
src/js/app.js
262
src/js/app.js
@@ -24,6 +24,7 @@ const App = {
|
||||
|
||||
async function init() {
|
||||
console.log('🔀 Chat Switchboard initializing...');
|
||||
initBranding(); // Apply branding before splash is visible
|
||||
API.loadTokens();
|
||||
|
||||
let health = null;
|
||||
@@ -102,6 +103,59 @@ async function saveSettings() {
|
||||
} catch (e) { console.warn('Settings sync failed:', e.message); }
|
||||
}
|
||||
|
||||
// ── Avatar Preview ──────────────────────────
|
||||
function updateAvatarPreview(dataURI) {
|
||||
const preview = document.getElementById('avatarPreview');
|
||||
const letter = document.getElementById('avatarPreviewLetter');
|
||||
const removeBtn = document.getElementById('avatarRemoveBtn');
|
||||
const existingImg = preview.querySelector('img');
|
||||
|
||||
if (dataURI) {
|
||||
letter.style.display = 'none';
|
||||
if (existingImg) {
|
||||
existingImg.src = dataURI;
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = dataURI;
|
||||
img.alt = 'Avatar';
|
||||
preview.insertBefore(img, letter);
|
||||
}
|
||||
removeBtn.style.display = '';
|
||||
} else {
|
||||
if (existingImg) existingImg.remove();
|
||||
const name = API.user?.display_name || API.user?.username || '?';
|
||||
letter.textContent = name[0].toUpperCase();
|
||||
letter.style.display = '';
|
||||
removeBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Preset avatar preview (admin form)
|
||||
function updatePresetAvatarPreview(dataURI) {
|
||||
const preview = document.getElementById('presetAvatarPreview');
|
||||
const placeholder = document.getElementById('presetAvatarPlaceholder');
|
||||
const removeBtn = document.getElementById('presetAvatarRemoveBtn');
|
||||
if (!preview) return;
|
||||
const existingImg = preview.querySelector('img');
|
||||
|
||||
if (dataURI) {
|
||||
placeholder.style.display = 'none';
|
||||
if (existingImg) {
|
||||
existingImg.src = dataURI;
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = dataURI;
|
||||
img.alt = 'Preset avatar';
|
||||
preview.insertBefore(img, placeholder);
|
||||
}
|
||||
removeBtn.style.display = '';
|
||||
} else {
|
||||
if (existingImg) existingImg.remove();
|
||||
placeholder.style.display = '';
|
||||
removeBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Models ───────────────────────────────────
|
||||
|
||||
// Client-side known model capabilities — used when backend hasn't synced yet.
|
||||
@@ -188,6 +242,7 @@ async function fetchModels() {
|
||||
isPreset,
|
||||
presetId: m.preset_id || null,
|
||||
presetScope: m.preset_scope || null,
|
||||
presetAvatar: m.preset_avatar || null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -843,6 +898,41 @@ function initListeners() {
|
||||
document.getElementById('profileChangePwForm').style.display = '';
|
||||
});
|
||||
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
|
||||
|
||||
// Avatar upload
|
||||
document.getElementById('avatarUploadBtn').addEventListener('click', () => {
|
||||
document.getElementById('avatarFileInput').click();
|
||||
});
|
||||
document.getElementById('avatarFileInput').addEventListener('change', async function() {
|
||||
const file = this.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const data = await API.uploadAvatar(reader.result);
|
||||
if (data.avatar) {
|
||||
API.user.avatar = data.avatar;
|
||||
API.saveTokens();
|
||||
updateAvatarPreview(data.avatar);
|
||||
UI.updateUser();
|
||||
UI.toast('Avatar updated');
|
||||
}
|
||||
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
this.value = '';
|
||||
});
|
||||
document.getElementById('avatarRemoveBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await API.deleteAvatar();
|
||||
API.user.avatar = null;
|
||||
API.saveTokens();
|
||||
updateAvatarPreview(null);
|
||||
UI.updateUser();
|
||||
UI.toast('Avatar removed');
|
||||
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
|
||||
});
|
||||
|
||||
document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
|
||||
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
|
||||
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
|
||||
@@ -907,6 +997,7 @@ function initListeners() {
|
||||
document.getElementById('adminPresetIcon').value = '';
|
||||
document.getElementById('adminPresetTemp').value = '';
|
||||
document.getElementById('adminPresetMaxTokens').value = '';
|
||||
updatePresetAvatarPreview(null);
|
||||
const f = document.getElementById('adminAddPresetForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
@@ -914,9 +1005,55 @@ function initListeners() {
|
||||
document.getElementById('adminAddPresetForm').style.display = 'none';
|
||||
_editingPresetId = null;
|
||||
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
|
||||
updatePresetAvatarPreview(null);
|
||||
});
|
||||
document.getElementById('adminCreatePresetBtn')?.addEventListener('click', createAdminPreset);
|
||||
|
||||
// Admin — preset avatar
|
||||
document.getElementById('presetAvatarUploadBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('presetAvatarFileInput').click();
|
||||
});
|
||||
document.getElementById('presetAvatarFileInput')?.addEventListener('change', async function() {
|
||||
const file = this.files[0];
|
||||
if (!file) return;
|
||||
if (!_editingPresetId) {
|
||||
// For new presets: show preview, upload after create
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => updatePresetAvatarPreview(reader.result);
|
||||
reader.readAsDataURL(file);
|
||||
this.value = '';
|
||||
return;
|
||||
}
|
||||
// For existing presets: upload immediately
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const data = await API.adminUploadPresetAvatar(_editingPresetId, reader.result);
|
||||
if (data.avatar) {
|
||||
updatePresetAvatarPreview(data.avatar);
|
||||
await UI.loadAdminPresets(true);
|
||||
await fetchModels();
|
||||
UI.toast('Preset avatar updated');
|
||||
}
|
||||
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
this.value = '';
|
||||
});
|
||||
document.getElementById('presetAvatarRemoveBtn')?.addEventListener('click', async () => {
|
||||
if (!_editingPresetId) {
|
||||
updatePresetAvatarPreview(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await API.adminDeletePresetAvatar(_editingPresetId);
|
||||
updatePresetAvatarPreview(null);
|
||||
await UI.loadAdminPresets(true);
|
||||
await fetchModels();
|
||||
UI.toast('Preset avatar removed');
|
||||
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
|
||||
});
|
||||
|
||||
// Admin — banner controls
|
||||
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
||||
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
|
||||
@@ -1195,6 +1332,8 @@ function editAdminPreset(id) {
|
||||
if (modelSel) modelSel.value = p.base_model_id || '';
|
||||
const cfgSel = document.getElementById('adminPresetConfig');
|
||||
if (cfgSel) cfgSel.value = p.api_config_id || '';
|
||||
// Avatar preview
|
||||
updatePresetAvatarPreview(p.avatar || null);
|
||||
document.getElementById('adminCreatePresetBtn').textContent = 'Update';
|
||||
}
|
||||
|
||||
@@ -1221,13 +1360,24 @@ async function createAdminPreset() {
|
||||
if (!isNaN(maxTok) && maxTok > 0) preset.max_tokens = maxTok;
|
||||
|
||||
try {
|
||||
let presetId = _editingPresetId;
|
||||
if (_editingPresetId) {
|
||||
await API.adminUpdatePreset(_editingPresetId, preset);
|
||||
UI.toast('Preset updated', 'success');
|
||||
} else {
|
||||
await API.adminCreatePreset(preset);
|
||||
const result = await API.adminCreatePreset(preset);
|
||||
presetId = result?.id || result?.preset?.id;
|
||||
UI.toast('Preset created', 'success');
|
||||
}
|
||||
|
||||
// Upload pending avatar for new presets
|
||||
const pendingImg = document.querySelector('#presetAvatarPreview img');
|
||||
if (!_editingPresetId && presetId && pendingImg?.src?.startsWith('data:')) {
|
||||
try {
|
||||
await API.adminUploadPresetAvatar(presetId, pendingImg.src);
|
||||
} catch (e) { console.warn('Preset avatar upload failed:', e.message); }
|
||||
}
|
||||
|
||||
_editingPresetId = null;
|
||||
document.getElementById('adminAddPresetForm').style.display = 'none';
|
||||
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
|
||||
@@ -1237,6 +1387,7 @@ async function createAdminPreset() {
|
||||
document.getElementById('adminPresetIcon').value = '';
|
||||
document.getElementById('adminPresetTemp').value = '';
|
||||
document.getElementById('adminPresetMaxTokens').value = '';
|
||||
updatePresetAvatarPreview(null);
|
||||
await UI.loadAdminPresets();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
@@ -1563,6 +1714,115 @@ async function deleteNote() {
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Branding ────────────────────────────────
|
||||
|
||||
function initBranding() {
|
||||
const b = window.__BRANDING__;
|
||||
if (!b || typeof b !== 'object') return;
|
||||
|
||||
const brandBase = (window.__BASE__ || '') + '/branding/';
|
||||
|
||||
// Accent color → CSS custom property
|
||||
if (b.accent_color) {
|
||||
document.documentElement.style.setProperty('--accent-color', b.accent_color);
|
||||
}
|
||||
|
||||
// Page title
|
||||
if (b.org_name) {
|
||||
document.title = b.org_name;
|
||||
}
|
||||
|
||||
// Sidebar brand text
|
||||
const sidebarText = document.getElementById('brandSidebarText');
|
||||
if (sidebarText && b.org_name) sidebarText.textContent = b.org_name;
|
||||
|
||||
// Splash hero wordmark
|
||||
const wordmark = document.getElementById('brandWordmark');
|
||||
if (wordmark && b.org_name) wordmark.textContent = b.org_name;
|
||||
|
||||
// Splash headline — replace if branding provides it
|
||||
if (b.headline) {
|
||||
const headline = document.getElementById('brandHeadline');
|
||||
if (headline) headline.textContent = b.headline;
|
||||
}
|
||||
|
||||
// Splash tagline
|
||||
const tagline = document.getElementById('brandTagline');
|
||||
if (tagline && b.tagline) tagline.textContent = b.tagline;
|
||||
|
||||
// Auth card text
|
||||
if (b.org_name) {
|
||||
const authHeader = document.querySelector('.auth-card-header h2');
|
||||
if (authHeader) authHeader.textContent = 'Welcome to ' + b.org_name;
|
||||
const authSub = document.querySelector('.auth-card-header p');
|
||||
if (authSub) authSub.textContent = 'Sign in to continue';
|
||||
}
|
||||
const authFooter = document.getElementById('brandAuthFooter');
|
||||
if (authFooter && b.tagline) authFooter.textContent = b.tagline;
|
||||
|
||||
// Logo — replace SVG with <img> in splash hero
|
||||
if (b.logo) {
|
||||
const logoUrl = brandBase + b.logo;
|
||||
|
||||
const logoEl = document.getElementById('brandLogo');
|
||||
if (logoEl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = logoUrl;
|
||||
img.alt = b.org_name || 'Logo';
|
||||
img.className = 'hero-logo-img';
|
||||
img.onerror = function() {
|
||||
this.style.display = 'none';
|
||||
const svg = logoEl.querySelector('svg');
|
||||
if (svg) svg.style.display = '';
|
||||
};
|
||||
const svg = logoEl.querySelector('svg');
|
||||
if (svg) svg.style.display = 'none';
|
||||
logoEl.appendChild(img);
|
||||
}
|
||||
|
||||
// Sidebar logo — replace emoji with small img
|
||||
const sidebarLogo = document.getElementById('brandSidebarLogo');
|
||||
if (sidebarLogo) {
|
||||
const originalEmoji = sidebarLogo.textContent;
|
||||
const img = document.createElement('img');
|
||||
img.src = logoUrl;
|
||||
img.alt = '';
|
||||
img.className = 'brand-logo-img';
|
||||
img.onerror = function() {
|
||||
this.remove();
|
||||
sidebarLogo.textContent = originalEmoji;
|
||||
};
|
||||
sidebarLogo.textContent = '';
|
||||
sidebarLogo.appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
// Favicon
|
||||
if (b.favicon) {
|
||||
const favUrl = brandBase + b.favicon;
|
||||
document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(link => {
|
||||
link.href = favUrl;
|
||||
});
|
||||
}
|
||||
|
||||
// Feature pills — custom array replaces defaults, empty array hides them
|
||||
if (b.pills !== undefined) {
|
||||
const pillsEl = document.getElementById('brandPills');
|
||||
if (pillsEl) {
|
||||
if (!Array.isArray(b.pills) || b.pills.length === 0) {
|
||||
pillsEl.style.display = 'none';
|
||||
} else {
|
||||
pillsEl.innerHTML = b.pills.map(p => {
|
||||
const style = p.style === 'accent' ? ' accent' : p.style === 'purple' ? ' purple' : '';
|
||||
return `<div class="hero-pill${style}"><span class="pill-icon">${p.icon || ''}</span> ${p.text}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎨 Branding applied:', b.org_name || '(defaults)');
|
||||
}
|
||||
|
||||
// ── Banners ──────────────────────────────────
|
||||
|
||||
async function initBanners() {
|
||||
|
||||
66
src/js/ui.js
66
src/js/ui.js
@@ -2,6 +2,26 @@
|
||||
// Chat Switchboard – UI
|
||||
// ==========================================
|
||||
|
||||
// ── Avatar Helper ────────────────────────────
|
||||
// Returns HTML for a message avatar: <img> if data URI available, emoji fallback.
|
||||
function avatarHTML(role, avatarDataURI) {
|
||||
if (avatarDataURI) {
|
||||
return `<img src="${avatarDataURI}" alt="" class="msg-avatar-img">`;
|
||||
}
|
||||
return role === 'user' ? '👤' : '🤖';
|
||||
}
|
||||
|
||||
// Look up the current model/preset avatar for assistant messages.
|
||||
function assistantAvatarURI(msg) {
|
||||
// Try to find preset avatar from the model that generated this message
|
||||
const modelId = msg?.model || msg?.preset_id;
|
||||
if (modelId) {
|
||||
const m = App.models.find(x => x.id === modelId || x.presetId === modelId);
|
||||
if (m?.presetAvatar) return m.presetAvatar;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const UI = {
|
||||
|
||||
// ── Sidebar ──────────────────────────────
|
||||
@@ -132,7 +152,7 @@ const UI = {
|
||||
return `
|
||||
<div class="message ${msg.role}" data-msg-id="${msgId}">
|
||||
<div class="msg-inner">
|
||||
<div class="msg-avatar">${isUser ? '👤' : '🤖'}</div>
|
||||
<div class="msg-avatar">${avatarHTML(msg.role, isUser ? API.user?.avatar : assistantAvatarURI(msg))}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-head">
|
||||
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</span>
|
||||
@@ -208,11 +228,13 @@ const UI = {
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
|
||||
const label = modelName || 'Assistant';
|
||||
const currentModel = App.models.find(m => m.id === App.settings.model);
|
||||
const streamAvatar = currentModel?.presetAvatar || null;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message assistant';
|
||||
div.innerHTML = `
|
||||
<div class="msg-inner">
|
||||
<div class="msg-avatar">🤖</div>
|
||||
<div class="msg-avatar">${avatarHTML('assistant', streamAvatar)}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-head"><span class="msg-role">${esc(label)}</span></div>
|
||||
<div class="msg-tools" id="streamTools" style="display:none"></div>
|
||||
@@ -394,7 +416,8 @@ const UI = {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'model-dropdown-item';
|
||||
div.dataset.value = m.id;
|
||||
div.innerHTML = `<span class="item-label">${esc(m.name || m.id)}</span>${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
|
||||
const avatar = m.presetAvatar ? `<img src="${m.presetAvatar}" class="dropdown-avatar" alt="">` : '';
|
||||
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}</span>${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
|
||||
div.addEventListener('click', () => {
|
||||
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
|
||||
UI._closeModelDropdown();
|
||||
@@ -475,7 +498,31 @@ const UI = {
|
||||
updateUser() {
|
||||
const name = API.user?.display_name || API.user?.username || 'User';
|
||||
const letter = (name[0] || '?').toUpperCase();
|
||||
document.getElementById('avatarLetter').textContent = letter;
|
||||
const avatarEl = document.getElementById('userAvatar');
|
||||
const letterEl = document.getElementById('avatarLetter');
|
||||
// Show avatar image or initial letter
|
||||
let existingImg = avatarEl.querySelector('.user-avatar-img');
|
||||
if (API.user?.avatar) {
|
||||
letterEl.style.display = 'none';
|
||||
if (existingImg) {
|
||||
existingImg.src = API.user.avatar;
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = API.user.avatar;
|
||||
img.alt = '';
|
||||
img.className = 'user-avatar-img';
|
||||
img.onerror = function() {
|
||||
this.remove();
|
||||
letterEl.style.display = '';
|
||||
letterEl.textContent = letter;
|
||||
};
|
||||
avatarEl.insertBefore(img, letterEl);
|
||||
}
|
||||
} else {
|
||||
if (existingImg) existingImg.remove();
|
||||
letterEl.style.display = '';
|
||||
letterEl.textContent = letter;
|
||||
}
|
||||
document.getElementById('userName').textContent = name;
|
||||
},
|
||||
|
||||
@@ -490,12 +537,14 @@ const UI = {
|
||||
document.getElementById('sendBtn').disabled = on;
|
||||
if (on) {
|
||||
const container = document.getElementById('chatMessages');
|
||||
const currentModel = App.models.find(m => m.id === App.settings.model);
|
||||
const typingAvatar = currentModel?.presetAvatar || null;
|
||||
const typing = document.createElement('div');
|
||||
typing.id = 'typingIndicator';
|
||||
typing.className = 'message assistant';
|
||||
typing.innerHTML = `
|
||||
<div class="msg-inner">
|
||||
<div class="msg-avatar">🤖</div>
|
||||
<div class="msg-avatar">${avatarHTML('assistant', typingAvatar)}</div>
|
||||
<div class="msg-body"><div class="typing-dots"><span></span><span></span><span></span></div></div>
|
||||
</div>`;
|
||||
container.appendChild(typing);
|
||||
@@ -624,6 +673,7 @@ const UI = {
|
||||
const p = await API.getProfile();
|
||||
document.getElementById('profileDisplayName').value = p.display_name || '';
|
||||
document.getElementById('profileEmail').value = p.email || '';
|
||||
updateAvatarPreview(p.avatar || null);
|
||||
} catch (e) { /* optional */ }
|
||||
},
|
||||
|
||||
@@ -805,12 +855,14 @@ const UI = {
|
||||
UI._presetCache = {};
|
||||
el.innerHTML = list.map(p => {
|
||||
UI._presetCache[p.id] = p;
|
||||
const icon = p.icon ? p.icon + ' ' : '';
|
||||
const avatarEl = p.avatar
|
||||
? `<img src="${p.avatar}" class="preset-row-avatar" alt="">`
|
||||
: (p.icon ? `<span class="preset-row-icon">${p.icon}</span>` : '');
|
||||
const scope = p.scope === 'global' ? '<span class="badge-admin">global</span>' : '<span class="badge-user">personal</span>';
|
||||
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
|
||||
return `<div class="admin-preset-row">
|
||||
<div class="preset-info">
|
||||
<strong>${icon}${esc(p.name)}</strong> ${scope} ${status}
|
||||
<strong>${avatarEl}${esc(p.name)}</strong> ${scope} ${status}
|
||||
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
|
||||
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user