Upload 2 new, 10 modified files from chat-switchboard-v0.7.4.zip

This commit is contained in:
2026-02-21 23:00:58 +00:00
parent 1ec392879b
commit 99dd88f896
10 changed files with 519 additions and 11 deletions

View File

@@ -10,7 +10,7 @@ minor = add features (deprecate, don't remove), patch = fix something.
---
## Current State: v0.7.3
## Current State: v0.7.4
### ✅ Done
@@ -79,6 +79,9 @@ minor = add features (deprecate, don't remove), patch = fix something.
- [x] Conversation path: active-path context assembly, cursor-aware
- [x] White-label branding: volume mount, org name, logo, favicon, accent color, pills
- [x] Avatar system: user profile upload, preset avatars, avatar in messages/sidebar/dropdown
- [x] Chat search / filter in sidebar (real-time title filtering)
- [x] Command palette (Ctrl+K) with fuzzy search, chat jumping, keyboard nav
- [x] PWA manifest + service worker (offline shell, install prompt)
**CI/CD (Gitea Actions)**
- [x] Three-env pipeline (dev/test/prod)
@@ -112,7 +115,7 @@ org-wide presets, users create personal ones (if user providers are enabled).
("Code Reviewer (GPT-4o)", "Research Assistant (Claude Opus)")
- [x] Completion handler unwraps preset → base model + config overrides
## 0.7.x — Branding + Polish
## ~~0.7.x — Branding + Polish~~ ✅
White-label support and UX improvements that exploit existing infrastructure.
@@ -137,12 +140,12 @@ White-label support and UX improvements that exploit existing infrastructure.
- [x] Context assembly follows active path, not full channel history
**UX Polish**
- [ ] Chat search / filter in sidebar
- [x] Chat search / filter in sidebar
- [x] UI preferences: font size + UI scale (Appearance tab, localStorage)
- [x] Mobile responsive: hamburger menu, sidebar overlay, auto-collapse
- [x] Model name in message headers (preset name or model ID, not "Assistant")
- [ ] Keyboard shortcuts (Ctrl+K command palette)
- [ ] PWA manifest + offline shell + install prompt
- [x] Keyboard shortcuts (Ctrl+K command palette)
- [x] PWA manifest + offline shell + install prompt
---

View File

@@ -1 +1 @@
0.7.3
0.7.4

View File

@@ -44,6 +44,11 @@ sed -i \
-e "s|%%BRANDING_JSON%%|${BRANDING_JSON}|g" \
/usr/share/nginx/html/index.html
# Inject version into service worker
sed -i \
-e "s|%%APP_VERSION%%|${APP_VERSION}|g" \
/usr/share/nginx/html/sw.js
echo "✅ Frontend configured: BASE_PATH=${BASE_PATH:-/} VERSION=${APP_VERSION}"
# ── Generate nginx config ───────────────────

View File

@@ -108,6 +108,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
archived := c.DefaultQuery("archived", "false")
folder := c.Query("folder")
channelType := c.DefaultQuery("type", "") // empty = all types
search := strings.TrimSpace(c.Query("search"))
// Count total
countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2`
@@ -124,6 +125,11 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
countArgs = append(countArgs, folder)
argN++
}
if search != "" {
countQuery += ` AND title ILIKE $` + strconv.Itoa(argN)
countArgs = append(countArgs, "%"+search+"%")
argN++
}
var total int
if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
@@ -156,9 +162,13 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
args = append(args, folder)
argN++
}
if search != "" {
query += ` AND c.title ILIKE $` + strconv.Itoa(argN)
args = append(args, "%"+search+"%")
argN++
}
query += ` ORDER BY c.is_pinned DESC, c.updated_at DESC
LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
query += ` ORDER BY c.is_pinned DESC, c.updated_at DESC LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
args = append(args, perPage, offset)
rows, err := database.DB.Query(query, args...)

View File

@@ -1288,6 +1288,121 @@ button { font-family: var(--font); cursor: pointer; }
display: flex; gap: 8px; margin-top: 12px;
}
/* ── Sidebar Search ──────────────────────── */
.sidebar-search {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; margin: 4px 6px;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); transition: border-color var(--transition);
}
.sidebar-search:focus-within { border-color: var(--accent); }
.sidebar-search svg { color: var(--text-3); flex-shrink: 0; }
.sidebar-search input {
flex: 1; background: none; border: none; color: var(--text);
font-size: 12px; font-family: var(--font); outline: none;
min-width: 0;
}
.sidebar-search input::placeholder { color: var(--text-3); }
.sidebar-search-clear {
background: none; border: none; color: var(--text-3); cursor: pointer;
font-size: 11px; padding: 2px 4px; border-radius: 4px;
transition: color var(--transition); display: none;
}
.sidebar-search-clear:hover { color: var(--text); }
.sidebar-search.has-value .sidebar-search-clear { display: block; }
.sidebar.collapsed .sidebar-search { display: none; }
.sidebar-search-empty {
color: var(--text-3); font-size: 12px; text-align: center;
padding: 1.5rem 0.5rem;
}
/* ── Command Palette ─────────────────────── */
.cmd-palette-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: none; align-items: flex-start; justify-content: center;
z-index: 2000; backdrop-filter: blur(4px);
padding-top: min(20vh, 160px);
}
.cmd-palette-overlay.active { display: flex; }
.cmd-palette {
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-lg); width: 90%; max-width: 480px;
box-shadow: 0 16px 64px rgba(0,0,0,0.6);
animation: modal-in 0.12s ease;
overflow: hidden;
}
.cmd-input-wrap {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; border-bottom: 1px solid var(--border);
}
.cmd-input-wrap svg { color: var(--text-3); flex-shrink: 0; }
.cmd-input-wrap input {
flex: 1; background: none; border: none; color: var(--text);
font-size: 15px; font-family: var(--font); outline: none;
}
.cmd-input-wrap input::placeholder { color: var(--text-3); }
.cmd-kbd {
font-size: 10px; font-family: var(--mono); color: var(--text-3);
background: var(--bg); border: 1px solid var(--border);
border-radius: 4px; padding: 2px 6px; flex-shrink: 0;
}
.cmd-results {
max-height: 320px; overflow-y: auto;
padding: 4px;
}
.cmd-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; border-radius: var(--radius);
cursor: pointer; font-size: 13px; color: var(--text-2);
transition: background 60ms ease, color 60ms ease;
}
.cmd-item:hover, .cmd-item.active {
background: var(--accent-dim); color: var(--text);
}
.cmd-item svg { flex-shrink: 0; color: var(--text-3); }
.cmd-item.active svg { color: var(--accent); }
.cmd-item-label { flex: 1; }
.cmd-item-hint {
font-size: 11px; color: var(--text-3); flex-shrink: 0;
}
.cmd-item.active .cmd-item-hint { color: var(--accent); }
.cmd-group-label {
font-size: 10px; font-weight: 600; color: var(--text-3);
padding: 8px 12px 4px; text-transform: uppercase; letter-spacing: 0.5px;
}
.cmd-footer {
display: flex; align-items: center; gap: 16px;
padding: 8px 16px; border-top: 1px solid var(--border);
font-size: 11px; color: var(--text-3);
}
.cmd-footer kbd {
font-size: 10px; font-family: var(--mono);
background: var(--bg); border: 1px solid var(--border);
border-radius: 3px; padding: 1px 4px;
}
/* ── PWA Install Banner ──────────────────── */
.pwa-install-banner {
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
background: var(--bg-raised); border: 1px solid var(--border-light);
border-radius: var(--radius-lg); padding: 10px 16px;
display: flex; align-items: center; gap: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5); z-index: 3000;
font-size: 13px; color: var(--text-2);
animation: pwa-banner-in 0.3s ease;
max-width: 90vw;
}
@keyframes pwa-banner-in { from { opacity: 0; transform: translateX(-50%) translateY(20px); } }
/* ── Responsive ──────────────────────────── */
.mobile-menu-btn {

View File

@@ -11,6 +11,10 @@
<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">
<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">
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
<link rel="stylesheet" href="branding/custom.css" onerror="this.remove()">
</head>
@@ -56,6 +60,11 @@
</div>
</div>
<div class="sidebar-search" id="sidebarSearch">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="chatSearchInput" placeholder="Search chats…" autocomplete="off">
<button class="sidebar-search-clear" id="chatSearchClear" title="Clear"></button>
</div>
<div class="sidebar-chats" id="chatHistory"></div>
<!-- Notes shortcut -->
@@ -546,6 +555,23 @@
</div>
</div>
<!-- Command Palette (Ctrl+K) -->
<div class="cmd-palette-overlay" id="cmdPalette">
<div class="cmd-palette">
<div class="cmd-input-wrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="cmdInput" placeholder="Type a command…" autocomplete="off" spellcheck="false">
<kbd class="cmd-kbd">esc</kbd>
</div>
<div class="cmd-results" id="cmdResults"></div>
<div class="cmd-footer">
<span><kbd>↑↓</kbd> navigate</span>
<span><kbd></kbd> run</span>
<span><kbd>esc</kbd> close</span>
</div>
</div>
</div>
<div class="toast-container" id="toastContainer"></div>
<!-- Vendor libs -->
@@ -557,5 +583,51 @@
<script src="js/api.js?v=%%APP_VERSION%%"></script>
<script src="js/ui.js?v=%%APP_VERSION%%"></script>
<script src="js/app.js?v=%%APP_VERSION%%"></script>
<script>
// ── Service Worker Registration ─────────
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js').then(reg => {
console.log('SW registered, scope:', reg.scope);
// Check for updates periodically
setInterval(() => reg.update(), 60 * 60 * 1000);
}).catch(err => console.warn('SW registration failed:', err));
});
}
// ── PWA Install Prompt ──────────────────
let _deferredInstallPrompt = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
_deferredInstallPrompt = e;
_showInstallBanner();
});
function _showInstallBanner() {
if (document.getElementById('pwaInstallBanner')) return;
const banner = document.createElement('div');
banner.id = 'pwaInstallBanner';
banner.className = 'pwa-install-banner';
banner.innerHTML =
'<span>Install Chat Switchboard for a better experience</span>' +
'<button class="btn-small btn-primary" onclick="_installPWA()">Install</button>' +
'<button class="btn-small" onclick="this.parentElement.remove()">Dismiss</button>';
document.body.appendChild(banner);
}
function _installPWA() {
if (!_deferredInstallPrompt) return;
_deferredInstallPrompt.prompt();
_deferredInstallPrompt.userChoice.then(() => {
_deferredInstallPrompt = null;
document.getElementById('pwaInstallBanner')?.remove();
});
}
window.addEventListener('appinstalled', () => {
document.getElementById('pwaInstallBanner')?.remove();
_deferredInstallPrompt = null;
});
</script>
</body>
</html>

View File

@@ -858,6 +858,13 @@ function initListeners() {
}, 300);
});
// Sidebar chat search
const _chatSearchInput = document.getElementById('chatSearchInput');
_chatSearchInput?.addEventListener('input', () => UI.renderChatList());
document.getElementById('chatSearchClear')?.addEventListener('click', () => {
if (_chatSearchInput) { _chatSearchInput.value = ''; UI.renderChatList(); _chatSearchInput.focus(); }
});
// Chat actions
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
@@ -1097,11 +1104,38 @@ function initListeners() {
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Escape: stop generation → close command palette → close topmost modal
if (e.key === 'Escape') {
if (App.isGenerating) { stopGeneration(); return; }
// Close topmost open modal
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
closeCmdPalette(); return;
}
const open = [...document.querySelectorAll('.modal-overlay.active')];
if (open.length) closeModal(open[open.length - 1].id);
return;
}
// Ctrl/Cmd+K: command palette
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
toggleCmdPalette();
return;
}
// Ctrl/Cmd+Shift+N: new chat
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'N') {
e.preventDefault();
newChat();
return;
}
// Ctrl/Cmd+Shift+S: focus sidebar search
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') {
e.preventDefault();
const sb = document.getElementById('sidebar');
if (sb.classList.contains('collapsed')) UI.toggleSidebar();
document.getElementById('chatSearchInput')?.focus();
return;
}
});
@@ -1111,6 +1145,151 @@ function initListeners() {
});
}
// ── Command Palette ──────────────────────────
const _cmdCommands = [
// Navigation
{ id: 'new-chat', label: 'New Chat', group: 'Navigation', hint: '⌘⇧N', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>', action: () => newChat() },
{ id: 'search-chats', label: 'Search Chats', group: 'Navigation', hint: '⌘⇧S', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>', action: () => { const sb = document.getElementById('sidebar'); if (sb.classList.contains('collapsed')) UI.toggleSidebar(); document.getElementById('chatSearchInput')?.focus(); } },
{ id: 'toggle-sidebar', label: 'Toggle Sidebar', group: 'Navigation', hint: '', icon: '<svg width="16" height="16" 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>', action: () => UI.toggleSidebar() },
// Tools
{ id: 'notes', label: 'Open Notes', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>', action: () => openNotes() },
{ id: 'export-md', label: 'Export Chat (Markdown)', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', action: () => UI.exportChat('md') },
{ id: 'export-json', label: 'Export Chat (JSON)', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', action: () => UI.exportChat('json') },
// Settings
{ id: 'settings', label: 'Settings', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>', action: () => UI.openSettings() },
{ id: 'admin', label: 'Admin Panel', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>', action: () => UI.openAdmin(), visible: () => API.user?.role === 'admin' },
{ id: 'debug', label: 'Debug Log', group: 'Settings', hint: '⌘⇧D', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2"/><path d="M9 6a2 2 0 0 0-2 2v3"/><circle cx="12" cy="14" r="6"/></svg>', action: () => openDebugModal() },
// Account
{ id: 'sign-out', label: 'Sign Out', group: 'Account', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>', action: () => handleLogout() },
];
let _cmdActiveIndex = 0;
function toggleCmdPalette() {
const el = document.getElementById('cmdPalette');
if (el.classList.contains('active')) { closeCmdPalette(); return; }
openCmdPalette();
}
function openCmdPalette() {
const el = document.getElementById('cmdPalette');
const input = document.getElementById('cmdInput');
el.classList.add('active');
input.value = '';
_cmdActiveIndex = 0;
_renderCmdResults('');
input.focus();
// Wire up input events (use named handler to avoid duplicates)
input.oninput = () => { _cmdActiveIndex = 0; _renderCmdResults(input.value); };
input.onkeydown = _handleCmdKey;
// Close on overlay click
el.onclick = (e) => { if (e.target === el) closeCmdPalette(); };
}
function closeCmdPalette() {
const el = document.getElementById('cmdPalette');
el.classList.remove('active');
document.getElementById('cmdInput').oninput = null;
document.getElementById('cmdInput').onkeydown = null;
}
function _handleCmdKey(e) {
const results = document.getElementById('cmdResults');
const items = results.querySelectorAll('.cmd-item');
if (!items.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
_cmdActiveIndex = (_cmdActiveIndex + 1) % items.length;
_highlightCmdItem(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
_cmdActiveIndex = (_cmdActiveIndex - 1 + items.length) % items.length;
_highlightCmdItem(items);
} else if (e.key === 'Enter') {
e.preventDefault();
items[_cmdActiveIndex]?.click();
}
}
function _highlightCmdItem(items) {
items.forEach((it, i) => it.classList.toggle('active', i === _cmdActiveIndex));
items[_cmdActiveIndex]?.scrollIntoView({ block: 'nearest' });
}
function _getVisibleCommands() {
return _cmdCommands.filter(c => !c.visible || c.visible());
}
function _renderCmdResults(query) {
const el = document.getElementById('cmdResults');
const q = query.trim().toLowerCase();
let commands = _getVisibleCommands();
// Add recent chats as commands when searching
if (q) {
const chatMatches = App.chats
.filter(c => (c.title || '').toLowerCase().includes(q))
.slice(0, 5)
.map(c => ({
id: 'chat-' + c.id,
label: c.title || 'Untitled',
group: 'Chats',
hint: _relativeTime(c.updatedAt),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
action: () => selectChat(c.id),
}));
commands = [...chatMatches, ...commands];
}
// Filter by query
if (q) {
commands = commands.filter(c => c.label.toLowerCase().includes(q));
}
if (commands.length === 0) {
el.innerHTML = '<div class="cmd-group-label" style="padding:16px 12px;text-transform:none;letter-spacing:0">No matching commands</div>';
return;
}
// Clamp active index
_cmdActiveIndex = Math.min(_cmdActiveIndex, commands.length - 1);
// Render grouped
let html = '';
let lastGroup = '';
commands.forEach((c, i) => {
if (c.group !== lastGroup) {
lastGroup = c.group;
html += `<div class="cmd-group-label">${c.group}</div>`;
}
html += `<div class="cmd-item${i === _cmdActiveIndex ? ' active' : ''}" data-cmd="${c.id}">
${c.icon}
<span class="cmd-item-label">${c.label}</span>
${c.hint ? `<span class="cmd-item-hint">${c.hint}</span>` : ''}
</div>`;
});
el.innerHTML = html;
// Attach click handlers
el.querySelectorAll('.cmd-item').forEach((item, i) => {
item.addEventListener('click', () => {
closeCmdPalette();
commands[i].action();
});
item.addEventListener('mouseenter', () => {
_cmdActiveIndex = i;
_highlightCmdItem(el.querySelectorAll('.cmd-item'));
});
});
}
// ── Settings Handlers ────────────────────────
function handleSaveSettings() {

View File

@@ -56,7 +56,26 @@ const UI = {
renderChatList() {
const el = document.getElementById('chatHistory');
if (App.chats.length === 0) {
const searchInput = document.getElementById('chatSearchInput');
const searchWrap = document.getElementById('sidebarSearch');
const query = (searchInput?.value || '').trim().toLowerCase();
// Toggle clear button visibility
if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0);
// Filter chats by search query
let chats = App.chats;
if (query) {
chats = chats.filter(c =>
(c.title || '').toLowerCase().includes(query)
);
}
if (chats.length === 0 && query) {
el.innerHTML = `<div class="sidebar-search-empty">No chats matching "${esc(query)}"</div>`;
return;
}
if (chats.length === 0) {
el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
return;
}
@@ -69,7 +88,7 @@ const UI = {
const groups = { today: [], yesterday: [], week: [], older: [] };
App.chats.forEach(c => {
chats.forEach(c => {
const d = new Date(c.updatedAt);
if (d >= today) groups.today.push(c);
else if (d >= yesterday) groups.yesterday.push(c);

29
src/manifest.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "Chat Switchboard",
"short_name": "Switchboard",
"description": "Self-hosted AI chat — unified interface for multiple providers",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#0e0e10",
"theme_color": "#0e0e10",
"orientation": "any",
"icons": [
{
"src": "favicon-32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "favicon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}

76
src/sw.js Normal file
View File

@@ -0,0 +1,76 @@
// ==========================================
// Chat Switchboard Service Worker
// ==========================================
// Caches the app shell for offline / instant load.
// API calls always go to network (never cached).
// Version string is injected by the frontend entrypoint.
const CACHE_NAME = 'switchboard-%%APP_VERSION%%';
// App shell files to pre-cache on install
const SHELL_FILES = [
'./',
'./index.html',
'./css/styles.css',
'./js/debug.js',
'./js/events.js',
'./js/api.js',
'./js/ui.js',
'./js/app.js',
'./vendor/marked.min.js',
'./vendor/purify.min.js',
'./favicon.svg',
'./favicon-32.png',
'./favicon-192.png',
'./manifest.json',
];
// Install: pre-cache the shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(SHELL_FILES))
.then(() => self.skipWaiting())
);
});
// Activate: purge old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(k => k !== CACHE_NAME)
.map(k => caches.delete(k))
)
).then(() => self.clients.claim())
);
});
// Fetch: network-first for API/WS, cache-first for shell assets
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Never cache API calls, WebSocket upgrades, or branding assets
if (url.pathname.includes('/api/') ||
url.pathname.includes('/ws') ||
url.pathname.includes('/branding/') ||
event.request.method !== 'GET') {
return;
}
// Cache-first for app shell, network fallback
event.respondWith(
caches.match(event.request).then(cached => {
const fetchPromise = fetch(event.request).then(response => {
// Update cache with fresh version
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
}).catch(() => cached); // offline fallback to cache
return cached || fetchPromise;
})
);
});