diff --git a/Dockerfile.frontend b/Dockerfile.frontend index decb9eb..04a0557 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -7,17 +7,6 @@ # Also used for the lite (unmanaged) deployment. # ========================================== -# Build stage -FROM alpine:3.19 AS builder - -WORKDIR /app -RUN apk add --no-cache bash coreutils - -COPY build.sh ./ -COPY src ./src - -RUN chmod +x build.sh && ./build.sh - # Production stage FROM nginx:alpine @@ -25,7 +14,7 @@ FROM nginx:alpine COPY nginx.frontend.conf /etc/nginx/conf.d/default.conf # Built SPA files -COPY --from=builder /app/standalone /usr/share/nginx/html +COPY src /usr/share/nginx/html EXPOSE 80 diff --git a/server/main.go b/server/main.go index f2a3e2b..80bee09 100644 --- a/server/main.go +++ b/server/main.go @@ -36,8 +36,16 @@ func main() { auth := handlers.NewAuthHandler(cfg) authLimiter := middleware.NewRateLimiter(1, 5) - api := r.Group("/api/v1") - { + api := r.Group("/api/v1") + { + api.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{ + "status": "ok", + "version": Version, + "database": database.IsConnected(), + }) + }) + authGroup := api.Group("/auth") authGroup.Use(authLimiter.Limit()) { diff --git a/src/css/styles.css b/src/css/styles.css index 0f598d7..5a22fe9 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -781,3 +781,72 @@ body { ::-webkit-scrollbar-track { background: var(--bg-secondary); } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } + +/* ── Connection Status ──────────────────── */ +.connection-status { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.7rem; + border-radius: 1rem; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s; + user-select: none; +} +.connection-status:hover { opacity: 0.8; } + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.connection-status.managed .status-dot { background: #22c55e; } +.connection-status.managed { color: #22c55e; border: 1px solid rgba(34, 197, 94, 0.3); } + +.connection-status.available .status-dot { background: #f59e0b; } +.connection-status.available { color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.3); } + +.connection-status.offline .status-dot { background: var(--text-secondary); } +.connection-status.offline { color: var(--text-secondary); border: 1px solid var(--border); } + +/* ── Auth Modal ─────────────────────────── */ +.auth-modal { + max-width: 400px; +} + +.auth-tabs { + display: flex; + gap: 0; + margin-bottom: 1.25rem; + border-bottom: 1px solid var(--border); +} + +.auth-tab { + flex: 1; + padding: 0.6rem; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.2s; +} + +.auth-tab:hover { color: var(--text-primary); } +.auth-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.auth-error { + color: #ef4444; + font-size: 0.85rem; + min-height: 1.2rem; + margin-top: 0.5rem; +} diff --git a/src/index.html b/src/index.html index 00a6d68..34e912d 100644 --- a/src/index.html +++ b/src/index.html @@ -10,6 +10,9 @@

🔀 Chat Switchboard

+
+ Offline +
+ + +
+ diff --git a/src/js/api.js b/src/js/api.js index b280073..579a1e1 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -170,6 +170,9 @@ async function sendApiRequest(messages) { }); updateChat(State.currentChatId, { messages, model }); + // Persist assistant response to backend (managed mode) + await onAssistantResponse(assistantContent, model); + if (!State.settings.stream) { renderMessages(messages); } diff --git a/src/js/app.js b/src/js/app.js index 8ede0ee..89f20e6 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -2,17 +2,44 @@ // Chat Switchboard - Main Application // ========================================== -function init() { +async function init() { console.log('🔀 Chat Switchboard initializing...'); - + loadSettings(); - loadChats(); loadModels(); + Backend.init(); + + // Auto-detect backend at same origin + if (!Backend.baseUrl) { + const health = await Backend.checkHealth(''); + if (health && health.database) { + Backend.baseUrl = window.location.origin; // same origin + Backend.save(); + console.log('🔗 Backend detected at same origin'); + } + } + + // If we have saved auth, validate it + if (Backend.accessToken) { + const health = await Backend.checkHealth(Backend.baseUrl); + if (!health) { + console.log('⚠️ Backend unreachable, falling back to unmanaged'); + Backend.clear(); + } + } + + await loadChats(); initializeEventListeners(); updateQuickModelSelector(); renderChatHistory(); - - console.log('✅ Chat Switchboard ready'); + updateConnectionStatus(); + + // If backend available but not logged in, show auth + if (Backend.baseUrl && !Backend.accessToken) { + showAuthModal(); + } + + console.log(`✅ Chat Switchboard ready (${Backend.isManaged ? 'managed' : 'unmanaged'} mode)`); } function initializeEventListeners() { @@ -23,7 +50,7 @@ function initializeEventListeners() { document.getElementById('cancelBtn').addEventListener('click', closeSettings); document.getElementById('saveBtn').addEventListener('click', handleSaveSettings); document.getElementById('fetchModelsBtn').addEventListener('click', () => fetchModels(true)); - + // Chat controls document.getElementById('newChatBtn').addEventListener('click', newChat); document.getElementById('sendBtn').addEventListener('click', sendMessage); @@ -31,7 +58,7 @@ function initializeEventListeners() { document.getElementById('regenerateBtn').addEventListener('click', regenerateResponse); document.getElementById('toggleSidebarBtn').addEventListener('click', toggleSidebar); document.getElementById('quickFetchBtn').addEventListener('click', () => fetchModels(false)); - + // Quick model selector document.getElementById('quickModel').addEventListener('change', function() { if (this.value) { @@ -39,17 +66,17 @@ function initializeEventListeners() { saveSettings(); } }); - + // Export dropdown document.getElementById('exportBtn').addEventListener('click', function(e) { e.stopPropagation(); document.getElementById('exportDropdown').classList.toggle('show'); }); - + document.addEventListener('click', function() { document.getElementById('exportDropdown').classList.remove('show'); }); - + // Message input const messageInput = document.getElementById('messageInput'); messageInput.addEventListener('keydown', handleKeyDown); @@ -57,7 +84,7 @@ function initializeEventListeners() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 200) + 'px'; }); - + // Settings modal model sync document.getElementById('model').addEventListener('change', function() { if (this.value) document.getElementById('modelCustom').value = ''; @@ -65,12 +92,31 @@ function initializeEventListeners() { document.getElementById('modelCustom').addEventListener('input', function() { if (this.value) document.getElementById('model').value = ''; }); - - // Close modal on overlay click + + // Close modals on overlay click document.getElementById('settingsModal').addEventListener('click', function(e) { if (e.target === this) closeSettings(); }); - + document.getElementById('authModal').addEventListener('click', function(e) { + if (e.target === this) { + // Only closeable if user has option to skip (unmanaged available) + if (!Backend.baseUrl || Backend.accessToken) { + closeAuthModal(); + } + } + }); + + // Auth modal + document.getElementById('authLoginBtn').addEventListener('click', handleLogin); + document.getElementById('authRegisterBtn').addEventListener('click', handleRegister); + document.getElementById('authTabLogin').addEventListener('click', () => switchAuthTab('login')); + document.getElementById('authTabRegister').addEventListener('click', () => switchAuthTab('register')); + document.getElementById('authCloseBtn').addEventListener('click', closeAuthModal); + document.getElementById('authSkipBtn').addEventListener('click', skipAuth); + + // Connection status click + document.getElementById('connectionStatus').addEventListener('click', handleConnectionClick); + // Global keyboard shortcuts document.addEventListener('keydown', function(e) { if (e.ctrlKey && e.key === ',') { @@ -86,6 +132,20 @@ function initializeEventListeners() { stopGeneration(); } else if (document.getElementById('settingsModal').classList.contains('active')) { closeSettings(); + } else if (document.getElementById('authModal').classList.contains('active')) { + closeAuthModal(); + } + } + }); + + // Enter key in auth form + document.getElementById('authPassword').addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + const activeTab = document.querySelector('.auth-tab.active'); + if (activeTab && activeTab.id === 'authTabRegister') { + // Focus email field first if registering + } else { + handleLogin(); } } }); @@ -101,11 +161,11 @@ function handleKeyDown(e) { function handleSaveSettings() { State.settings.apiEndpoint = document.getElementById('apiEndpoint').value.trim(); State.settings.apiKey = document.getElementById('apiKey').value.trim(); - + const modelCustom = document.getElementById('modelCustom').value.trim(); const modelSelect = document.getElementById('model').value; State.settings.model = modelCustom || modelSelect || 'gpt-3.5-turbo'; - + State.settings.stream = document.getElementById('streamResponse').checked; State.settings.saveHistory = document.getElementById('saveHistory').checked; State.settings.showThinking = document.getElementById('showThinking').checked; @@ -114,7 +174,7 @@ function handleSaveSettings() { State.settings.temperature = parseFloat(document.getElementById('temperature').value) || 0.7; State.settings.topP = parseFloat(document.getElementById('topP').value) || 1; State.settings.presencePenalty = parseFloat(document.getElementById('presencePenalty').value) || 0; - + saveSettings(); updateQuickModelSelector(); closeSettings(); @@ -139,24 +199,30 @@ function newChat() { document.getElementById('messageInput').focus(); } -function loadChat(chatId) { +async function loadChat(chatId) { const chat = getChat(chatId); - if (chat) { - State.currentChatId = chatId; - if (chat.model) { - State.settings.model = chat.model; - updateQuickModelSelector(); - } - renderMessages(chat.messages); - renderChatHistory(); - updateRegenerateButton(chat.messages); + if (!chat) return; + + State.currentChatId = chatId; + + // In managed mode, fetch messages if not cached + if (Backend.isManaged && chat.messages.length === 0 && chat.messageCount > 0) { + await loadMessages(chatId); } + + if (chat.model) { + State.settings.model = chat.model; + updateQuickModelSelector(); + } + renderMessages(chat.messages); + renderChatHistory(); + updateRegenerateButton(chat.messages); } -function handleDeleteChat(chatId, event) { +async function handleDeleteChat(chatId, event) { event.stopPropagation(); if (confirm('Delete this chat?')) { - deleteChat(chatId); + await deleteChat(chatId); if (State.currentChatId === chatId) { newChat(); } @@ -168,9 +234,9 @@ function handleDeleteChat(chatId, event) { async function sendMessage() { const input = document.getElementById('messageInput'); const message = input.value.trim(); - + if (!message || State.isGenerating) return; - + if (!State.settings.apiEndpoint || !State.settings.apiKey) { showToast('⚠️ Configure API settings first', 'warning'); openSettings(); @@ -179,56 +245,199 @@ async function sendMessage() { input.value = ''; input.style.height = 'auto'; - + let messages = []; - + if (State.settings.systemPrompt) { messages.push({ role: 'system', content: State.settings.systemPrompt }); } - + if (State.currentChatId) { const chat = getChat(State.currentChatId); if (chat) messages = [...chat.messages]; } - - messages.push({ - role: 'user', + + messages.push({ + role: 'user', content: message, timestamp: new Date().toISOString() }); - + if (!State.currentChatId) { const title = message.substring(0, 50) + (message.length > 50 ? '...' : ''); - const chat = createChat(title, messages); + const chat = await createChat(title, messages); + if (!chat) return; State.currentChatId = chat.id; } else { - updateChat(State.currentChatId, { messages }); + await updateChat(State.currentChatId, { messages }); + // Persist user message to backend + await persistMessage(State.currentChatId, 'user', message); } - + renderMessages(messages); renderChatHistory(); - + await sendApiRequest(messages); } async function regenerateResponse() { if (State.isGenerating || !State.currentChatId) return; - + const chat = getChat(State.currentChatId); if (!chat || chat.messages.length === 0) return; - + // Remove last assistant message(s) while (chat.messages.length > 0 && chat.messages[chat.messages.length - 1].role === 'assistant') { chat.messages.pop(); } - + if (chat.messages.length === 0) return; - - updateChat(State.currentChatId, { messages: chat.messages }); + + await updateChat(State.currentChatId, { messages: chat.messages }); renderMessages(chat.messages); - + await sendApiRequest(chat.messages); } +// ── Auth Flow ─────────────────────────────── + +function showAuthModal() { + document.getElementById('authModal').classList.add('active'); + document.getElementById('authLogin').value = ''; + document.getElementById('authPassword').value = ''; + document.getElementById('authUsername').value = ''; + document.getElementById('authEmail').value = ''; + document.getElementById('authError').textContent = ''; + switchAuthTab('login'); +} + +function closeAuthModal() { + document.getElementById('authModal').classList.remove('active'); +} + +function switchAuthTab(tab) { + document.getElementById('authTabLogin').classList.toggle('active', tab === 'login'); + document.getElementById('authTabRegister').classList.toggle('active', tab === 'register'); + document.getElementById('authLoginForm').style.display = tab === 'login' ? 'block' : 'none'; + document.getElementById('authRegisterForm').style.display = tab === 'register' ? 'block' : 'none'; + document.getElementById('authLoginBtn').style.display = tab === 'login' ? 'block' : 'none'; + document.getElementById('authRegisterBtn').style.display = tab === 'register' ? 'block' : 'none'; + document.getElementById('authError').textContent = ''; +} + +async function handleLogin() { + const login = document.getElementById('authLogin').value.trim(); + const password = document.getElementById('authPassword').value; + + if (!login || !password) { + document.getElementById('authError').textContent = 'Please fill in all fields'; + return; + } + + setAuthLoading(true); + try { + await Backend.login(login, password); + closeAuthModal(); + await loadChats(); + renderChatHistory(); + updateConnectionStatus(); + showToast(`✅ Welcome back, ${Backend.user.username}`, 'success'); + } catch (e) { + document.getElementById('authError').textContent = e.message; + } finally { + setAuthLoading(false); + } +} + +async function handleRegister() { + const username = document.getElementById('authUsername').value.trim(); + const email = document.getElementById('authEmail').value.trim(); + const password = document.getElementById('authRegPassword').value; + + if (!username || !email || !password) { + document.getElementById('authError').textContent = 'Please fill in all fields'; + return; + } + if (password.length < 8) { + document.getElementById('authError').textContent = 'Password must be at least 8 characters'; + return; + } + + setAuthLoading(true); + try { + await Backend.register(username, email, password); + closeAuthModal(); + await loadChats(); + renderChatHistory(); + updateConnectionStatus(); + showToast(`✅ Welcome, ${Backend.user.username}!`, 'success'); + } catch (e) { + document.getElementById('authError').textContent = e.message; + } finally { + setAuthLoading(false); + } +} + +function setAuthLoading(loading) { + const btns = document.querySelectorAll('#authLoginBtn, #authRegisterBtn'); + btns.forEach(btn => { + btn.disabled = loading; + if (loading) btn.dataset.origText = btn.textContent; + btn.textContent = loading ? '⏳ Please wait...' : (btn.dataset.origText || btn.textContent); + }); +} + +function skipAuth() { + closeAuthModal(); + Backend.clear(); + updateConnectionStatus(); + showToast('📴 Running in offline mode', 'success'); +} + +// ── Connection Status ─────────────────────── + +function updateConnectionStatus() { + const el = document.getElementById('connectionStatus'); + if (Backend.isManaged) { + el.className = 'connection-status managed'; + el.innerHTML = ` ${Backend.user?.username || 'Connected'}`; + el.title = 'Managed mode – click to sign out'; + } else if (Backend.baseUrl) { + el.className = 'connection-status available'; + el.innerHTML = ' Sign in'; + el.title = 'Backend available – click to sign in'; + } else { + el.className = 'connection-status offline'; + el.innerHTML = ' Offline'; + el.title = 'Unmanaged mode – data stored locally'; + } +} + +async function handleConnectionClick() { + if (Backend.isManaged) { + if (confirm('Sign out? Your chats are saved on the server.')) { + await Backend.logout(); + State.chats = []; + State.currentChatId = null; + loadChats(); + newChat(); + renderChatHistory(); + updateConnectionStatus(); + showToast('👋 Signed out', 'success'); + } + } else if (Backend.baseUrl) { + showAuthModal(); + } +} + +// ── Post-LLM Persistence ──────────────────── +// Called by api.js after assistant response is complete. + +async function onAssistantResponse(content, model) { + if (Backend.isManaged && State.currentChatId) { + await persistMessage(State.currentChatId, 'assistant', content, model); + } +} + // Initialize on DOM ready document.addEventListener('DOMContentLoaded', init); diff --git a/src/js/backend.js b/src/js/backend.js new file mode 100644 index 0000000..82411c5 --- /dev/null +++ b/src/js/backend.js @@ -0,0 +1,229 @@ +// ========================================== +// Switchboard Backend API Client +// ========================================== +// Handles auth + chat/message CRUD against the +// Switchboard backend. Token refresh is automatic. +// ========================================== + +const Backend = { + baseUrl: '', + accessToken: null, + refreshToken: null, + user: null, + _refreshPromise: null, + + // ── Connection ────────────────────────── + + get isConnected() { + return !!(this.baseUrl && this.accessToken); + }, + + get isManaged() { + return this.isConnected; + }, + + // ── Init ──────────────────────────────── + + init() { + // Auto-detect: if we're served from a domain with /api/v1, + // the backend is co-located. Otherwise check saved URL. + const saved = Storage.get('switchboard_backend'); + if (saved) { + this.baseUrl = saved.baseUrl || ''; + this.accessToken = saved.accessToken || null; + this.refreshToken = saved.refreshToken || null; + this.user = saved.user || null; + } + }, + + save() { + Storage.set('switchboard_backend', { + baseUrl: this.baseUrl, + accessToken: this.accessToken, + refreshToken: this.refreshToken, + user: this.user + }); + }, + + clear() { + this.accessToken = null; + this.refreshToken = null; + this.user = null; + this.save(); + }, + + // ── Auth ──────────────────────────────── + + async register(username, email, password) { + const data = await this._fetch('/api/v1/auth/register', { + method: 'POST', + body: JSON.stringify({ username, email, password }) + }, true); + this._setAuth(data); + return data; + }, + + async login(login, password) { + const data = await this._fetch('/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ login, password }) + }, true); + this._setAuth(data); + return data; + }, + + async logout() { + try { + await this._fetch('/api/v1/auth/logout', { + method: 'POST', + body: JSON.stringify({ refresh_token: this.refreshToken }) + }, true); + } catch (e) { + // Best-effort + } + this.clear(); + }, + + async refresh() { + // Deduplicate concurrent refresh calls + if (this._refreshPromise) return this._refreshPromise; + + this._refreshPromise = (async () => { + try { + const data = await this._fetch('/api/v1/auth/refresh', { + method: 'POST', + body: JSON.stringify({ refresh_token: this.refreshToken }) + }, true); + this._setAuth(data); + return true; + } catch (e) { + this.clear(); + return false; + } finally { + this._refreshPromise = null; + } + })(); + + return this._refreshPromise; + }, + + _setAuth(data) { + this.accessToken = data.access_token; + this.refreshToken = data.refresh_token; + this.user = data.user; + this.save(); + }, + + // ── Health ─────────────────────────────── + + async checkHealth(url) { + try { + const resp = await fetch(url + '/api/v1/health', { + signal: AbortSignal.timeout(5000) + }); + if (!resp.ok) return null; + return await resp.json(); + } catch (e) { + return null; + } + }, + + // ── Chats ─────────────────────────────── + + async listChats(page = 1, perPage = 50) { + return this._authedFetch(`/api/v1/chats?page=${page}&per_page=${perPage}`); + }, + + async createChat(title, model, systemPrompt) { + const body = { title }; + if (model) body.model = model; + if (systemPrompt) body.system_prompt = systemPrompt; + return this._authedFetch('/api/v1/chats', { + method: 'POST', + body: JSON.stringify(body) + }); + }, + + async getChat(chatId) { + return this._authedFetch(`/api/v1/chats/${chatId}`); + }, + + async updateChat(chatId, updates) { + return this._authedFetch(`/api/v1/chats/${chatId}`, { + method: 'PUT', + body: JSON.stringify(updates) + }); + }, + + async deleteChat(chatId) { + return this._authedFetch(`/api/v1/chats/${chatId}`, { + method: 'DELETE' + }); + }, + + // ── Messages ──────────────────────────── + + async listMessages(chatId, page = 1, perPage = 100) { + return this._authedFetch( + `/api/v1/chats/${chatId}/messages?page=${page}&per_page=${perPage}` + ); + }, + + async createMessage(chatId, role, content, model) { + const body = { role, content }; + if (model) body.model = model; + return this._authedFetch(`/api/v1/chats/${chatId}/messages`, { + method: 'POST', + body: JSON.stringify(body) + }); + }, + + // ── HTTP Layer ────────────────────────── + + async _authedFetch(path, opts = {}) { + try { + return await this._fetch(path, { + ...opts, + headers: { + ...opts.headers, + 'Authorization': `Bearer ${this.accessToken}` + } + }); + } catch (e) { + // If 401, try refresh once + if (e.status === 401 && this.refreshToken) { + const refreshed = await this.refresh(); + if (refreshed) { + return this._fetch(path, { + ...opts, + headers: { + ...opts.headers, + 'Authorization': `Bearer ${this.accessToken}` + } + }); + } + } + throw e; + } + }, + + async _fetch(path, opts = {}, skipAuth = false) { + const url = this.baseUrl + path; + const resp = await fetch(url, { + ...opts, + headers: { + 'Content-Type': 'application/json', + ...opts.headers + } + }); + + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + const err = new Error(body.error || `HTTP ${resp.status}`); + err.status = resp.status; + throw err; + } + + return resp.json(); + } +}; diff --git a/src/js/state.js b/src/js/state.js index 467e184..7caf2df 100644 --- a/src/js/state.js +++ b/src/js/state.js @@ -1,5 +1,8 @@ // ========================================== -// State Management +// State Management (Mode-Aware) +// ========================================== +// In unmanaged mode: localStorage (same as before) +// In managed mode: backend API with local cache // ========================================== const State = { @@ -23,6 +26,8 @@ const State = { abortController: null }; +// ── Settings (always localStorage) ────────── + function loadSettings() { const saved = Storage.get('chatSwitchboard_settings'); if (saved) { @@ -34,6 +39,8 @@ function saveSettings() { Storage.set('chatSwitchboard_settings', State.settings); } +// ── Models (always localStorage) ──────────── + function loadModels() { State.models = Storage.get('chatSwitchboard_models', []); } @@ -42,42 +49,130 @@ function saveModels() { Storage.set('chatSwitchboard_models', State.models); } -function loadChats() { - State.chats = Storage.get('chatSwitchboard_chats', []); +// ── Chats ─────────────────────────────────── + +async function loadChats() { + if (Backend.isManaged) { + try { + const resp = await Backend.listChats(1, 100); + State.chats = (resp.data || []).map(c => ({ + id: c.id, + title: c.title, + model: c.model || '', + systemPrompt: c.system_prompt || '', + isArchived: c.is_archived, + isPinned: c.is_pinned, + messageCount: c.message_count || 0, + messages: [], // loaded on demand + createdAt: c.created_at, + updatedAt: c.updated_at + })); + } catch (e) { + console.error('Failed to load chats from backend:', e); + showToast('⚠️ Failed to load chats', 'error'); + } + } else { + State.chats = Storage.get('chatSwitchboard_chats', []); + } } function saveChats() { - if (State.settings.saveHistory) { + // In managed mode, individual operations handle persistence. + // In unmanaged mode, write to localStorage. + if (!Backend.isManaged && State.settings.saveHistory) { Storage.set('chatSwitchboard_chats', State.chats); } } -function createChat(title, messages) { - const chat = { - id: Date.now().toString(), - title, - messages, - model: State.settings.model, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }; - State.chats.unshift(chat); - if (State.chats.length > 100) { - State.chats = State.chats.slice(0, 100); +async function createChat(title, messages) { + if (Backend.isManaged) { + try { + const resp = await Backend.createChat( + title, + State.settings.model, + State.settings.systemPrompt + ); + const chat = { + id: resp.id, + title: resp.title, + model: resp.model || '', + systemPrompt: resp.system_prompt || '', + messages: messages || [], + messageCount: 0, + createdAt: resp.created_at, + updatedAt: resp.updated_at + }; + State.chats.unshift(chat); + + // Persist any initial messages (system prompt, first user msg) + for (const msg of (messages || [])) { + await Backend.createMessage(chat.id, msg.role, msg.content, msg.model); + } + chat.messageCount = (messages || []).length; + + return chat; + } catch (e) { + console.error('Failed to create chat:', e); + showToast('⚠️ Failed to create chat', 'error'); + return null; + } + } else { + const chat = { + id: Date.now().toString(), + title, + messages: messages || [], + model: State.settings.model, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + State.chats.unshift(chat); + if (State.chats.length > 100) { + State.chats = State.chats.slice(0, 100); + } + saveChats(); + return chat; } - saveChats(); - return chat; } -function updateChat(chatId, updates) { +async function updateChat(chatId, updates) { const chat = State.chats.find(c => c.id === chatId); - if (chat) { + if (!chat) return; + + if (Backend.isManaged) { + // Separate backend-updateable fields from local-only fields + const backendUpdates = {}; + if (updates.title !== undefined) backendUpdates.title = updates.title; + if (updates.model !== undefined) backendUpdates.model = updates.model; + if (updates.is_archived !== undefined) backendUpdates.is_archived = updates.is_archived; + if (updates.is_pinned !== undefined) backendUpdates.is_pinned = updates.is_pinned; + + if (Object.keys(backendUpdates).length > 0) { + try { + await Backend.updateChat(chatId, backendUpdates); + } catch (e) { + console.error('Failed to update chat:', e); + } + } + + // Update local cache + Object.assign(chat, updates, { updatedAt: new Date().toISOString() }); + } else { Object.assign(chat, updates, { updatedAt: new Date().toISOString() }); saveChats(); } } -function deleteChat(chatId) { +async function deleteChat(chatId) { + if (Backend.isManaged) { + try { + await Backend.deleteChat(chatId); + } catch (e) { + console.error('Failed to delete chat:', e); + showToast('⚠️ Failed to delete chat', 'error'); + return; + } + } + State.chats = State.chats.filter(c => c.id !== chatId); saveChats(); } @@ -89,3 +184,39 @@ function getChat(chatId) { function getCurrentChat() { return State.currentChatId ? getChat(State.currentChatId) : null; } + +// ── Messages (managed mode) ───────────────── + +async function loadMessages(chatId) { + if (!Backend.isManaged) return; + + const chat = getChat(chatId); + if (!chat) return; + + // Skip if already loaded + if (chat.messages && chat.messages.length > 0) return; + + try { + const resp = await Backend.listMessages(chatId, 1, 200); + chat.messages = (resp.data || []).map(m => ({ + role: m.role, + content: m.content, + model: m.model || '', + timestamp: m.created_at, + _backendId: m.id + })); + } catch (e) { + console.error('Failed to load messages:', e); + showToast('⚠️ Failed to load messages', 'error'); + } +} + +async function persistMessage(chatId, role, content, model) { + if (!Backend.isManaged) return; + + try { + await Backend.createMessage(chatId, role, content, model); + } catch (e) { + console.error('Failed to persist message:', e); + } +}