Changeset 0.9.2 (#52)
This commit is contained in:
@@ -97,12 +97,14 @@ a:hover { text-decoration: underline; }
|
||||
background: none; border: none; color: var(--text);
|
||||
cursor: pointer; font-size: 13px; white-space: nowrap;
|
||||
transition: background var(--transition); width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.sb-brand:hover { background: var(--bg-hover); }
|
||||
.brand-logo { font-size: 18px; flex-shrink: 0; display: inline; }
|
||||
.brand-collapse { flex-shrink: 0; display: none; color: var(--text-2); }
|
||||
.sb-brand:hover .brand-logo { display: none; }
|
||||
.sb-brand:hover .brand-collapse { display: inline; }
|
||||
.brand-logo { flex-shrink: 0; width: 18px; height: 18px; display: grid; place-items: center; }
|
||||
.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; transition: opacity var(--transition); }
|
||||
.brand-collapse { flex-shrink: 0; width: 0; overflow: hidden; color: var(--text-2); opacity: 0; transition: opacity var(--transition); position: absolute; left: 10px; }
|
||||
.sb-brand:hover .brand-logo-img { opacity: 0; }
|
||||
.sb-brand:hover .brand-collapse { opacity: 1; }
|
||||
.brand-text { font-weight: 600; font-size: 14px; }
|
||||
|
||||
.sb-btn {
|
||||
@@ -123,6 +125,7 @@ a:hover { text-decoration: underline; }
|
||||
.sidebar.collapsed .sidebar-top { padding: 10px 0 8px; align-items: center; }
|
||||
.sidebar.collapsed .sidebar-bottom { padding: 8px 0; display: flex; flex-direction: column; align-items: center; }
|
||||
.sidebar.collapsed .sb-brand { justify-content: center; padding: 8px 0; gap: 0; }
|
||||
.sidebar.collapsed .brand-collapse { left: 50%; transform: translateX(-50%); }
|
||||
.sidebar.collapsed .sb-btn { justify-content: center; padding: 8px 0; gap: 0; }
|
||||
.sidebar.collapsed .split-btn-main { justify-content: center; padding: 8px 0; gap: 0; }
|
||||
.sidebar.collapsed .user-btn { justify-content: center; padding: 6px 0; gap: 0; }
|
||||
@@ -666,6 +669,7 @@ a:hover { text-decoration: underline; }
|
||||
justify-content: center; height: 100%; color: var(--text-3); gap: 0.25rem;
|
||||
}
|
||||
.empty-logo { font-size: 3rem; margin-bottom: 0.5rem; }
|
||||
.empty-logo-img { width: 64px; height: 64px; object-fit: contain; }
|
||||
.empty-state h2 { font-size: 1.25rem; font-weight: 600; color: var(--text-2); }
|
||||
.empty-state p { font-size: 0.85rem; }
|
||||
|
||||
@@ -811,7 +815,7 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.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;
|
||||
|
||||
BIN
src/favicon-256.png
Normal file
BIN
src/favicon-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.2 KiB |
BIN
src/favicon.ico
Normal file
BIN
src/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 682 B |
@@ -1,10 +1,53 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="12" fill="#1a1a2e"/>
|
||||
<!-- Arrow 1: bottom-left → top-right -->
|
||||
<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"/>
|
||||
<!-- Arrow 2: top-left → bottom-right (gap at crossover) -->
|
||||
<path d="M12 16 L24 30" stroke="#a78bfa" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
||||
<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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3d3935"/>
|
||||
<stop offset="100%" stop-color="#252220"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Panel background -->
|
||||
<rect x="4" y="4" width="56" height="56" rx="10" fill="url(#panel)" stroke="#4a4540" stroke-width="1.5"/>
|
||||
|
||||
<!-- Static jack holes (dark rings) -->
|
||||
<circle cx="22" cy="23" r="8" fill="#111"/>
|
||||
<circle cx="42" cy="23" r="8" fill="#111"/>
|
||||
<circle cx="42" cy="43" r="8" fill="#111"/>
|
||||
<circle cx="22" cy="43" r="8" fill="#111"/>
|
||||
|
||||
<!--
|
||||
Cascading color rotation (clockwise: TL → TR → BR → BL)
|
||||
dur=12s, each cascade round=3s, fade=0.4s out + 0.4s in
|
||||
As dot N fades in, dot N+1 begins fading out (overlap)
|
||||
|
||||
Blue=#2D7DD2 Orange=#E8852E Green=#2EAA4E Purple=#9B59B6
|
||||
-->
|
||||
|
||||
<!-- TL: Blue → Purple → Green → Orange (offset 0s) -->
|
||||
<circle cx="22" cy="23" r="5.5" fill="#2D7DD2">
|
||||
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
|
||||
keyTimes="0;0.0333;0.0667;0.25;0.2833;0.3167;0.5;0.5333;0.5667;0.75;0.7833;0.8167;1"
|
||||
values="#2D7DD2;#111;#9B59B6;#9B59B6;#111;#2EAA4E;#2EAA4E;#111;#E8852E;#E8852E;#111;#2D7DD2;#2D7DD2"/>
|
||||
</circle>
|
||||
|
||||
<!-- TR: Orange → Blue → Purple → Green (offset 0.4s) -->
|
||||
<circle cx="42" cy="23" r="5.5" fill="#E8852E">
|
||||
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
|
||||
keyTimes="0;0.0333;0.0667;0.1;0.2833;0.3167;0.35;0.5333;0.5667;0.6;0.7833;0.8167;0.85;1"
|
||||
values="#E8852E;#E8852E;#111;#2D7DD2;#2D7DD2;#111;#9B59B6;#9B59B6;#111;#2EAA4E;#2EAA4E;#111;#E8852E;#E8852E"/>
|
||||
</circle>
|
||||
|
||||
<!-- BR: Purple → Green → Orange → Blue (offset 0.8s) -->
|
||||
<circle cx="42" cy="43" r="5.5" fill="#9B59B6">
|
||||
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
|
||||
keyTimes="0;0.0667;0.1;0.1333;0.3167;0.35;0.3833;0.5667;0.6;0.6333;0.8167;0.85;0.8833;1"
|
||||
values="#9B59B6;#9B59B6;#111;#2EAA4E;#2EAA4E;#111;#E8852E;#E8852E;#111;#2D7DD2;#2D7DD2;#111;#9B59B6;#9B59B6"/>
|
||||
</circle>
|
||||
|
||||
<!-- BL: Green → Orange → Blue → Purple (offset 1.2s) -->
|
||||
<circle cx="22" cy="43" r="5.5" fill="#2EAA4E">
|
||||
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
|
||||
keyTimes="0;0.1;0.1333;0.1667;0.35;0.3833;0.4167;0.6;0.6333;0.6667;0.85;0.8833;0.9167;1"
|
||||
values="#2EAA4E;#2EAA4E;#111;#E8852E;#E8852E;#111;#2D7DD2;#2D7DD2;#111;#9B59B6;#9B59B6;#111;#2EAA4E;#2EAA4E"/>
|
||||
</circle>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 642 B After Width: | Height: | Size: 2.5 KiB |
@@ -9,10 +9,12 @@
|
||||
<script>window.__ENV__ = '%%ENVIRONMENT%%'; if (window.__ENV__.includes('%%')) window.__ENV__ = 'development';</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="manifest" href="manifest.json">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg?v=%%APP_VERSION%%">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico?v=%%APP_VERSION%%">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png?v=%%APP_VERSION%%">
|
||||
<link rel="icon" type="image/png" sizes="256x256" href="favicon-256.png?v=%%APP_VERSION%%">
|
||||
<link rel="apple-touch-icon" sizes="256x256" href="favicon-256.png?v=%%APP_VERSION%%">
|
||||
<link rel="manifest" href="manifest.json?v=%%APP_VERSION%%">
|
||||
<meta name="theme-color" content="#0e0e10">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
@@ -32,7 +34,7 @@
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-top">
|
||||
<button class="sb-brand" id="sidebarToggle" title="Toggle sidebar">
|
||||
<span class="brand-logo" id="brandSidebarLogo">🔀</span>
|
||||
<span class="brand-logo" id="brandSidebarLogo"><img src="favicon-32.png" class="brand-logo-img" alt=""></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" id="brandSidebarText">Chat Switchboard</span>
|
||||
</button>
|
||||
@@ -128,7 +130,7 @@
|
||||
|
||||
<div class="messages" id="chatMessages">
|
||||
<div class="empty-state">
|
||||
<div class="empty-logo">🔀</div>
|
||||
<div class="empty-logo"><img src="favicon-256.png" class="empty-logo-img" alt=""></div>
|
||||
<h2>Chat Switchboard</h2>
|
||||
<p>Select a model and start chatting</p>
|
||||
</div>
|
||||
@@ -168,14 +170,7 @@
|
||||
<div class="hero-content">
|
||||
<div class="hero-logo-row">
|
||||
<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"/>
|
||||
<path d="M12 16 L24 30" stroke="#a78bfa" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
||||
<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>
|
||||
<img src="favicon-256.png" alt="Chat Switchboard" style="width:100%;height:100%;border-radius:12px">
|
||||
</div>
|
||||
<div class="hero-wordmark" id="brandWordmark">Chat <span>Switchboard</span></div>
|
||||
</div>
|
||||
@@ -183,7 +178,7 @@
|
||||
<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 purple"><span class="pill-icon"><img src="favicon-32.png" style="width:14px;height:14px;vertical-align:middle" alt=""></span> Multi-provider routing</div>
|
||||
<div class="hero-pill"><span class="pill-icon">🔑</span> Bring your own API keys</div>
|
||||
<div class="hero-pill"><span class="pill-icon">🏠</span> Self-hosted</div>
|
||||
<div class="hero-pill"><span class="pill-icon">✈️</span> Air-gap ready</div>
|
||||
@@ -392,6 +387,21 @@
|
||||
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
||||
<button class="btn-small" id="settingsTeamAddPresetBtn" style="margin-top:6px">+ New Team Preset</button>
|
||||
</section>
|
||||
<!-- Team Activity (audit scoped to team members) -->
|
||||
<section class="settings-section">
|
||||
<h4 style="font-size:13px;margin-bottom:6px">Activity Log</h4>
|
||||
<div class="audit-filters" style="margin-bottom:8px">
|
||||
<select id="teamAuditFilterAction" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamAuditLog()">
|
||||
<option value="">All actions</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="settingsTeamAudit" style="max-height:300px;overflow-y:auto"></div>
|
||||
<div id="teamAuditPagination" class="audit-pagination" style="display:none">
|
||||
<button class="btn-small" id="teamAuditPrevBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage - 1)">← Prev</button>
|
||||
<span id="teamAuditPageInfo" style="font-size:11px;color:var(--text-3)"></span>
|
||||
<button class="btn-small" id="teamAuditNextBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage + 1)">Next →</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -395,6 +395,16 @@ const API = {
|
||||
teamUpdateProvider(teamId, providerId, updates) { return this._put(`/api/v1/teams/${teamId}/providers/${providerId}`, updates); },
|
||||
teamDeleteProvider(teamId, providerId) { return this._del(`/api/v1/teams/${teamId}/providers/${providerId}`); },
|
||||
teamListProviderModels(teamId, providerId) { return this._get(`/api/v1/teams/${teamId}/providers/${providerId}/models`); },
|
||||
teamListAudit(teamId, params) {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', params.page);
|
||||
if (params?.per_page) q.set('per_page', params.per_page);
|
||||
if (params?.action) q.set('action', params.action);
|
||||
if (params?.actor_id) q.set('actor_id', params.actor_id);
|
||||
if (params?.resource_type) q.set('resource_type', params.resource_type);
|
||||
return this._get(`/api/v1/teams/${teamId}/audit?${q}`);
|
||||
},
|
||||
teamListAuditActions(teamId) { return this._get(`/api/v1/teams/${teamId}/audit/actions`); },
|
||||
|
||||
// ── User Presets ─────────────────────────
|
||||
listPresets() { return this._get('/api/v1/presets'); },
|
||||
|
||||
80
src/js/ui.js
80
src/js/ui.js
@@ -334,7 +334,7 @@ const UI = {
|
||||
showEmptyState() {
|
||||
document.getElementById('chatMessages').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-logo">🔀</div>
|
||||
<div class="empty-logo"><img src="favicon-256.png" class="empty-logo-img" alt=""></div>
|
||||
<h2>Chat Switchboard</h2>
|
||||
<p>Select a model and start chatting</p>
|
||||
</div>`;
|
||||
@@ -927,6 +927,7 @@ const UI = {
|
||||
|
||||
async openTeamManage(teamId, teamName) {
|
||||
UI._managingTeamId = teamId;
|
||||
UI._teamAuditPage = 1;
|
||||
document.getElementById('settingsTeamPicker').style.display = 'none';
|
||||
const manage = document.getElementById('settingsTeamManage');
|
||||
manage.style.display = '';
|
||||
@@ -935,10 +936,14 @@ const UI = {
|
||||
document.getElementById('settingsTeamAddPreset').style.display = 'none';
|
||||
document.getElementById('settingsTeamAddProvider').style.display = 'none';
|
||||
document.getElementById('settingsTeamEditProvider').style.display = 'none';
|
||||
// Reset team audit filter
|
||||
const af = document.getElementById('teamAuditFilterAction');
|
||||
if (af) { af.innerHTML = '<option value="">All actions</option>'; }
|
||||
await Promise.all([
|
||||
UI.loadTeamManageMembers(teamId),
|
||||
UI.loadTeamManageProviders(teamId),
|
||||
UI.loadTeamManagePresets(teamId),
|
||||
UI.loadTeamAuditLog(1),
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -1021,6 +1026,79 @@ const UI = {
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
_teamAuditPage: 1,
|
||||
_teamAuditPerPage: 20,
|
||||
|
||||
async loadTeamAuditLog(page) {
|
||||
const teamId = UI._managingTeamId;
|
||||
if (!teamId) return;
|
||||
if (page) UI._teamAuditPage = page;
|
||||
|
||||
const el = document.getElementById('settingsTeamAudit');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
|
||||
// Populate action filter on first load
|
||||
const actionSel = document.getElementById('teamAuditFilterAction');
|
||||
if (actionSel && actionSel.options.length <= 1) {
|
||||
try {
|
||||
const resp = await API.teamListAuditActions(teamId);
|
||||
(resp.actions || []).forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a;
|
||||
opt.textContent = a;
|
||||
actionSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { /* optional */ }
|
||||
}
|
||||
|
||||
const params = {
|
||||
page: UI._teamAuditPage,
|
||||
per_page: UI._teamAuditPerPage,
|
||||
action: document.getElementById('teamAuditFilterAction')?.value || '',
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await API.teamListAudit(teamId, params);
|
||||
const entries = resp.data || [];
|
||||
const total = resp.total || 0;
|
||||
const totalPages = Math.ceil(total / UI._teamAuditPerPage);
|
||||
|
||||
if (entries.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">No activity recorded for this team</div>';
|
||||
} else {
|
||||
el.innerHTML = entries.map(e => {
|
||||
const ts = new Date(e.created_at);
|
||||
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
||||
const actor = e.actor_name || 'system';
|
||||
const meta = UI._formatAuditMeta(e.metadata);
|
||||
return `<div class="audit-row">
|
||||
<div class="audit-main">
|
||||
<span class="audit-action">${esc(e.action)}</span>
|
||||
<span class="audit-actor">${esc(actor)}</span>
|
||||
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
|
||||
</div>
|
||||
<div class="audit-meta">
|
||||
${meta ? `<span class="audit-details">${meta}</span>` : ''}
|
||||
<span class="audit-time">${timeStr}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const pgEl = document.getElementById('teamAuditPagination');
|
||||
if (totalPages > 1) {
|
||||
pgEl.style.display = 'flex';
|
||||
document.getElementById('teamAuditPageInfo').textContent = `Page ${UI._teamAuditPage} of ${totalPages} (${total} entries)`;
|
||||
document.getElementById('teamAuditPrevBtn').disabled = UI._teamAuditPage <= 1;
|
||||
document.getElementById('teamAuditNextBtn').disabled = UI._teamAuditPage >= totalPages;
|
||||
} else {
|
||||
pgEl.style.display = 'none';
|
||||
}
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadTeamPresetModelDropdown(teamId) {
|
||||
const sel = document.getElementById('teamPreset_model');
|
||||
if (!sel) return;
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "favicon-192.png",
|
||||
"sizes": "192x192",
|
||||
"src": "favicon-256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user