Upload 2 new, 10 modified files from chat-switchboard-v0.7.4.zip
This commit is contained in:
13
ROADMAP.md
13
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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 ───────────────────
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
181
src/js/app.js
181
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: '<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() {
|
||||
|
||||
23
src/js/ui.js
23
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 = `<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
29
src/manifest.json
Normal 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
76
src/sw.js
Normal 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;
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user