diff --git a/ROADMAP.md b/ROADMAP.md index 187bcee..8b1014c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 --- diff --git a/VERSION b/VERSION index f38fc53..0a1ffad 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.3 +0.7.4 diff --git a/docker-entrypoint-fe.sh b/docker-entrypoint-fe.sh index e785d96..4b5092c 100644 --- a/docker-entrypoint-fe.sh +++ b/docker-entrypoint-fe.sh @@ -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 ─────────────────── diff --git a/server/handlers/channels.go b/server/handlers/channels.go index aca419d..72a75b7 100644 --- a/server/handlers/channels.go +++ b/server/handlers/channels.go @@ -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...) diff --git a/src/css/styles.css b/src/css/styles.css index 10cc991..60d8734 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -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 { diff --git a/src/index.html b/src/index.html index 8000938..6929978 100644 --- a/src/index.html +++ b/src/index.html @@ -11,6 +11,10 @@ + + + + @@ -56,6 +60,11 @@ + @@ -546,6 +555,23 @@ + +
+
+
+ + + esc +
+
+ +
+
+
@@ -557,5 +583,51 @@ + diff --git a/src/js/app.js b/src/js/app.js index 798fa38..4aa4a2c 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -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: '', action: () => newChat() }, + { id: 'search-chats', label: 'Search Chats', group: 'Navigation', hint: '⌘⇧S', icon: '', 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: '', action: () => UI.toggleSidebar() }, + + // Tools + { id: 'notes', label: 'Open Notes', group: 'Tools', hint: '', icon: '', action: () => openNotes() }, + { id: 'export-md', label: 'Export Chat (Markdown)', group: 'Tools', hint: '', icon: '', action: () => UI.exportChat('md') }, + { id: 'export-json', label: 'Export Chat (JSON)', group: 'Tools', hint: '', icon: '', action: () => UI.exportChat('json') }, + + // Settings + { id: 'settings', label: 'Settings', group: 'Settings', hint: '', icon: '', action: () => UI.openSettings() }, + { id: 'admin', label: 'Admin Panel', group: 'Settings', hint: '', icon: '', action: () => UI.openAdmin(), visible: () => API.user?.role === 'admin' }, + { id: 'debug', label: 'Debug Log', group: 'Settings', hint: '⌘⇧D', icon: '', action: () => openDebugModal() }, + + // Account + { id: 'sign-out', label: 'Sign Out', group: 'Account', hint: '', icon: '', 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: '', + 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 = '
No matching commands
'; + 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 += `
${c.group}
`; + } + html += `
+ ${c.icon} + ${c.label} + ${c.hint ? `${c.hint}` : ''} +
`; + }); + 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() { diff --git a/src/js/ui.js b/src/js/ui.js index 48108aa..1fa77f6 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -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 = ``; + return; + } + if (chats.length === 0) { el.innerHTML = ''; 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); diff --git a/src/manifest.json b/src/manifest.json new file mode 100644 index 0000000..3de5aab --- /dev/null +++ b/src/manifest.json @@ -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" + } + ] +} diff --git a/src/sw.js b/src/sw.js new file mode 100644 index 0000000..de3305a --- /dev/null +++ b/src/sw.js @@ -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; + }) + ); +});