diff --git a/public/index.html b/public/index.html index 75321b1..9cb9ce9 100644 --- a/public/index.html +++ b/public/index.html @@ -11,7 +11,7 @@
-

Secure AI Access

+

Best AI chat in the big 2026

Welcome to DeepFake

Log in to continue your conversation or create an account to get started.

@@ -30,7 +30,8 @@ @@ -44,10 +45,11 @@ @@ -86,4 +106,4 @@ - + \ No newline at end of file diff --git a/public/script.js b/public/script.js index 710f853..706e989 100644 --- a/public/script.js +++ b/public/script.js @@ -15,9 +15,15 @@ const msgInput = document.getElementById("msgInput"); const sendBtn = document.getElementById("send"); const authScreens = document.querySelectorAll(".auth-screen"); const toggleButtons = document.querySelectorAll(".auth-toggle .toggle"); +const chatList = document.getElementById("chatList"); +const newChatBtn = document.getElementById("newChat"); +const renameChatBtn = document.getElementById("renameChat"); +const deleteChatBtn = document.getElementById("deleteChat"); +const activeChatTitle = document.getElementById("activeChatTitle"); let isProcessing = false; -let chatHistory = JSON.parse(localStorage.getItem('chatHistory')) || []; +let chatSessions = []; +let activeChatId = null; const displayModels = async () => { const select = document.getElementById("model"); @@ -40,12 +46,12 @@ const displayModels = async () => { }); } -async function post(url, data) { +async function request(url, data, method = "POST") { try { const res = await fetch(url, { - method: "POST", + method, headers: {"Content-Type": "application/json"}, - body: JSON.stringify(data) + body: data ? JSON.stringify(data) : undefined }); return await res.json(); } catch (err) { @@ -53,6 +59,10 @@ async function post(url, data) { } } +async function post(url, data) { + return request(url, data, "POST"); +} + async function get(url) { try { const res = await fetch(url); @@ -69,50 +79,35 @@ function setAuthMessage(msg, state = "") { function setMessageContent(element, content) { if (window.marked && window.DOMPurify) { - const rendered = marked.parse(content, { breaks: true }); + const rendered = marked.parse(content, {breaks: true}); element.innerHTML = DOMPurify.sanitize(rendered); return; } - element.textContent = content; } function addMessage(content, isUser = false, isLoading = false, saveToHistory = true) { const msgDiv = document.createElement('div'); msgDiv.className = `msg ${isUser ? 'user' : 'bot'}${isLoading ? ' loading' : ''}`; - if (isLoading) { msgDiv.innerHTML = ` -
-
-
-
-
- `; +
+
+
+
+
+ `; } else { const header = document.createElement('div'); header.className = 'msg-header'; header.textContent = isUser ? 'You' : 'DeepFake'; - const body = document.createElement('div'); body.className = 'msg-content'; setMessageContent(body, content); - msgDiv.appendChild(header); msgDiv.appendChild(body); - - if (saveToHistory) { - chatHistory.push({ - content: content, - isUser: isUser, - timestamp: Date.now() - }); - localStorage.setItem('chatHistory', JSON.stringify(chatHistory)); - } } - messagesDiv.appendChild(msgDiv); - setTimeout(() => { messagesDiv.scrollTop = messagesDiv.scrollHeight; }, 100); @@ -120,17 +115,57 @@ function addMessage(content, isUser = false, isLoading = false, saveToHistory = return msgDiv; } -function loadChatHistory() { +function clearMessages() { messagesDiv.innerHTML = ''; - chatHistory.forEach(msg => { - addMessage(msg.content, msg.isUser, false, false); - }); } -function clearChatHistory() { - chatHistory = []; - localStorage.removeItem('chatHistory'); - messagesDiv.innerHTML = ''; + +function getChatById(chatId) { + return chatSessions.find(chat => chat.id === chatId); +} + +function bumpChat(chatId) { + const index = chatSessions.findIndex(chat => chat.id === chatId); + if (index <= 0) return; + const [chat] = chatSessions.splice(index, 1); + chatSessions.unshift(chat); + renderChatList(); +} + +function updateChatActionState() { + const hasChat = Boolean(activeChatId); + renameChatBtn.disabled = !hasChat; + deleteChatBtn.disabled = !hasChat; +} + + +function renderChatList() { + chatList.innerHTML = ''; + if (!chatSessions.length) { + const empty = document.createElement('p'); + empty.className = 'status'; + empty.textContent = 'No chats yet. Start a new one!'; + chatList.appendChild(empty); + return; + } + chatSessions.forEach(chat => { + const item = document.createElement('div'); + item.className = `chat-item${chat.id === activeChatId ? ' active' : ''}`; + const title = document.createElement('span'); + title.textContent = chat.title; + const actions = document.createElement('button'); + actions.type = 'button'; + actions.textContent = '✕'; + actions.title = 'Delete chat'; + actions.addEventListener('click', (event) => { + event.stopPropagation(); + deleteChat(chat.id); + }); + item.appendChild(title); + item.appendChild(actions); + item.addEventListener('click', () => setActiveChat(chat.id)); + chatList.appendChild(item); + }); } function setLoadingState(loading) { @@ -157,12 +192,96 @@ function showAuthScreen(target) { setAuthMessage('', ''); } +function setActiveChat(chatId) { + activeChatId = chatId; + const chat = getChatById(chatId); + activeChatTitle.textContent = chat ? chat.title : 'New chat'; + updateChatActionState(); + renderChatList(); + if (chatId) { + loadChatMessages(chatId); + } else { + clearMessages(); + } +} + + +async function loadChatSessions() { + const res = await get('/chats'); + if (res.error) return; + chatSessions = res.chats || []; + renderChatList(); + const activeExists = chatSessions.find(chat => chat.id === activeChatId); + if (activeExists) { + setActiveChat(activeChatId); + return; + } + if (!chatSessions.length) { + await createNewChat(); + return; + } + setActiveChat(chatSessions[0].id); +} + +async function loadChatMessages(chatId) { + const res = await get(`/chats/${chatId}/messages`); + if (res.error) return; + clearMessages(); + (res.messages || []).forEach(msg => { + addMessage(msg.content, msg.role === 'user', false, false); + + }); + messagesDiv.scrollTop = messagesDiv.scrollHeight; +} + +async function createNewChat() { + const res = await post('/chats', {title: 'New chat'}); + if (res.error || !res.chat) return; + chatSessions.unshift(res.chat); + setActiveChat(res.chat.id); + renderChatList(); +} + +async function renameChat(chatId) { + const chat = getChatById(chatId); + if (!chat) return; + const nextTitle = prompt('Rename chat', chat.title); + if (!nextTitle) return; + const trimmedTitle = nextTitle.trim(); + if (!trimmedTitle) return; + const res = await request(`/chats/${chatId}`, {title: trimmedTitle}, "PUT"); + if (res.error) return; + chat.title = trimmedTitle; + activeChatTitle.textContent = trimmedTitle; + renderChatList(); +} + + +async function deleteChat(chatId) { + const chat = getChatById(chatId); + if (!chat) return; + const confirmDelete = confirm(`Delete "${chat.title}"? This cannot be undone.`); + if (!confirmDelete) return; + const res = await request(`/chats/${chatId}`, null, "DELETE"); + if (res.error) return; + chatSessions = chatSessions.filter(item => item.id !== chatId); + if (activeChatId === chatId) { + if (chatSessions.length) { + setActiveChat(chatSessions[0].id); + } else { + activeChatId = null; + await createNewChat(); + } + } + renderChatList(); +} + async function checkSession() { const res = await get('/session'); if (res.user) { authDiv.style.display = "none"; - chatDiv.style.display = "grid"; - loadChatHistory(); + chatDiv.style.display = "block"; + await loadChatSessions(); msgInput.focus(); return true; } @@ -170,23 +289,11 @@ async function checkSession() { } async function loadServerChat() { - const res = await get("/chat/history"); - if (res.history && res.history.length) { - messagesDiv.innerHTML = ''; - res.history.forEach(msg => { - addMessage( - msg.content, - msg.role === "user", - false, - false - ); - }); - messagesDiv.scrollTop = messagesDiv.scrollHeight; - } + await loadChatSessions(); } async function handleAuth(endpoint, credentials) { - const { username, password } = credentials; + const {username, password} = credentials; const submitBtn = endpoint === "login" ? loginSubmit : registerSubmit; if (!username || !password) { @@ -207,7 +314,7 @@ async function handleAuth(endpoint, credentials) { if (endpoint === "login") { setAuthMessage("Login successful! Redirecting to chat...", 'success'); authDiv.style.display = "none"; - chatDiv.style.display = "grid"; + chatDiv.style.display = "block"; await loadServerChat(); msgInput.focus(); } else { @@ -223,6 +330,17 @@ async function sendMessage() { if (isProcessing) return; const msg = msgInput.value.trim(); if (!msg) return; + if (!activeChatId) { + await createNewChat(); + } + const currentChat = getChatById(activeChatId); + if (currentChat && currentChat.title === 'New chat') { + const autoTitle = msg.length > 40 ? `${msg.slice(0, 40)}…` : msg; + currentChat.title = autoTitle; + activeChatTitle.textContent = autoTitle; + renderChatList(); + await request(`/chats/${activeChatId}`, {title: autoTitle}, "PUT"); + } addMessage(msg, true); msgInput.value = ""; msgInput.style.height = 'auto'; @@ -230,12 +348,13 @@ async function sendMessage() { const loadingMsg = addMessage('', false, true, false); try { const model = document.getElementById("model").value; - const res = await post("/chat", {message: msg, model}); + const res = await post("/chat", {message: msg, model, chatId: activeChatId}); if (messagesDiv.contains(loadingMsg)) { messagesDiv.removeChild(loadingMsg); } if (res.reply) { addMessage(res.reply, false); + bumpChat(activeChatId); } else { addMessage(`[Error: ${res.error}]`, false); } @@ -274,7 +393,9 @@ document.getElementById("logout").onclick = async () => { await post("/logout", {}); chatDiv.style.display = "none"; authDiv.style.display = "grid"; - clearChatHistory(); + clearMessages(); + chatSessions = []; + activeChatId = null; setAuthMessage("Logged out.", 'success'); showAuthScreen('login'); loginUsernameInput.value = ""; @@ -290,16 +411,25 @@ msgInput.addEventListener("keydown", (e) => { } }); -document.getElementById("clearChat").onclick = async () => { - await post("/chat/clear", {}); - clearChatHistory(); -}; +newChatBtn.addEventListener('click', () => createNewChat()); -msgInput.addEventListener('input', function () { - this.style.height = 'auto'; - this.style.height = Math.min(this.scrollHeight, 120) + 'px'; +renameChatBtn.addEventListener('click', () => { + if (activeChatId) { + renameChat(activeChatId); + } }); +deleteChatBtn.addEventListener('click', () => { + if (activeChatId) { + deleteChat(activeChatId); + } +}); ++ + msgInput.addEventListener('input', function () { + this.style.height = 'auto'; + this.style.height = Math.min(this.scrollHeight, 120) + 'px'; + }); + window.addEventListener('load', () => { checkSession(); displayModels(); diff --git a/public/style.css b/public/style.css index 977ce36..81b18b8 100644 --- a/public/style.css +++ b/public/style.css @@ -26,8 +26,8 @@ html, body { body { font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: radial-gradient(circle at 20% 20%, rgba(99,102,241,0.12), transparent 25%), - radial-gradient(circle at 80% 0%, rgba(139,92,246,0.12), transparent 25%), + background: radial-gradient(circle at 20% 20%, rgba(99, 102, 241, 0.12), transparent 25%), + radial-gradient(circle at 80% 0%, rgba(139, 92, 246, 0.12), transparent 25%), linear-gradient(135deg, #0b0b1d 0%, #0f1124 40%, #0b1020 100%); color: var(--text-primary); display: flex; @@ -64,7 +64,7 @@ body { content: ''; position: absolute; inset: 0; - background: linear-gradient(135deg, rgba(99,102,241,0.08), rgba(139,92,246,0.05)); + background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(139, 92, 246, 0.05)); opacity: 0.5; pointer-events: none; } @@ -125,8 +125,8 @@ h1 { } .auth-toggle { - background: rgba(255,255,255,0.02); - border: 1px solid rgba(255,255,255,0.07); + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.07); border-radius: 14px; padding: 6px; display: grid; @@ -148,7 +148,7 @@ h1 { .toggle.active { background: var(--gradient); color: white; - box-shadow: 0 10px 20px rgba(99,102,241,0.25); + box-shadow: 0 10px 20px rgba(99, 102, 241, 0.25); } .auth-screen { @@ -234,7 +234,9 @@ button:hover:not(:disabled) { box-shadow: 0 10px 25px rgba(124, 58, 237, 0.3); } -button:hover::before { left: 100%; } +button:hover::before { + left: 100%; +} button:disabled { opacity: 0.5; @@ -255,8 +257,8 @@ button:disabled { padding: 10px; border-radius: 10px; transition: all 0.3s ease; - background: rgba(255,255,255,0.02); - border: 1px dashed rgba(255,255,255,0.05); + background: rgba(255, 255, 255, 0.02); + border: 1px dashed rgba(255, 255, 255, 0.05); color: var(--text-secondary); } @@ -273,29 +275,135 @@ button:disabled { } #chat { - display: grid; - grid-template-rows: 1fr auto auto; - row-gap: 15px; height: 90vh; - width: 90%; - max-width: 1100px; + width: 95%; + max-width: 1200px; padding: 24px; - background: rgba(19,19,42,0.92); + background: rgba(19, 19, 42, 0.92); border-radius: 24px; box-shadow: 0 25px 50px -12px var(--shadow); min-height: 0; } +.chat-layout { + display: grid; + grid-template-columns: 280px 1fr; + gap: 20px; + height: 100%; + min-height: 0; +} + +.chat-sidebar { + background: rgba(10, 10, 20, 0.6); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 20px; + padding: 18px; + display: flex; + flex-direction: column; + gap: 16px; + min-height: 0; +} + +.sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.sidebar-header h2 { + margin: 2px 0 0; + font-size: 1.2rem; +} + +.secondary-action { + background: rgba(99, 102, 241, 0.15); + color: var(--text-primary); + border: 1px solid rgba(99, 102, 241, 0.35); + padding: 10px 14px; + border-radius: 12px; +} + +.chat-list { + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + padding-right: 4px; +} + +.chat-list::-webkit-scrollbar { + width: 6px; +} + +.chat-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.chat-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 12px; + border-radius: 14px; + border: 1px solid transparent; + background: rgba(15, 15, 35, 0.6); + cursor: pointer; + transition: all 0.2s ease; +} + +.chat-item:hover { + border-color: rgba(99, 102, 241, 0.4); + background: rgba(19, 19, 42, 0.9); +} + +.chat-item.active { + border-color: rgba(99, 102, 241, 0.7); + box-shadow: 0 10px 20px rgba(99, 102, 241, 0.2); +} + +.chat-item span { + font-size: 0.95rem; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.chat-item button { + background: transparent; + border: none; + color: var(--text-muted); + padding: 6px; + border-radius: 8px; + font-size: 14px; +} + +.chat-item button:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.08); +} + +.chat-main { + display: grid; + grid-template-rows: auto 1fr auto auto; + row-gap: 15px; + min-height: 0; +} + #messages { - grid-row: 1; + grid-row: 2; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; padding: 20px; margin: 0; - background: rgba(10,10,20,0.6); - border: 1px solid rgba(255,255,255,0.05); + background: rgba(10, 10, 20, 0.6); + border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 20px; backdrop-filter: blur(10px); scroll-behavior: smooth; @@ -303,7 +411,7 @@ button:disabled { } .input-container { - grid-row: 2; + grid-row: 3; display: flex; align-items: flex-end; gap: 12px; @@ -312,14 +420,23 @@ button:disabled { } .logout-container { - grid-row: 3; + grid-row: 4; text-align: center; margin-top: 10px; } -#messages::-webkit-scrollbar { width: 8px; } -#messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } -#messages::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } +#messages::-webkit-scrollbar { + width: 8px; +} + +#messages::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +#messages::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} #chat h1 { flex-shrink: 0; @@ -347,8 +464,14 @@ button:disabled { } @keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } .msg.user { @@ -447,13 +570,38 @@ button:disabled { color: #bfdbfe; } -.typing-indicator { display: flex; align-items: center; gap: 6px; height: 16px; } -.typing-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-secondary); animation: typingAnimation 1.2s infinite ease-in-out; } -.typing-dot:nth-child(1) { animation-delay: -0.32s; } -.typing-dot:nth-child(2) { animation-delay: -0.16s; } +.typing-indicator { + display: flex; + align-items: center; + gap: 6px; + height: 16px; +} + +.typing-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-secondary); + animation: typingAnimation 1.2s infinite ease-in-out; +} + +.typing-dot:nth-child(1) { + animation-delay: -0.32s; +} + +.typing-dot:nth-child(2) { + animation-delay: -0.16s; +} + @keyframes typingAnimation { - 0%, 80%, 100% { transform: scale(0.8); opacity: 0.5; } - 40% { transform: scale(1); opacity: 1; } + 0%, 80%, 100% { + transform: scale(0.8); + opacity: 0.5; + } + 40% { + transform: scale(1); + opacity: 1; + } } #msgInput { @@ -479,36 +627,160 @@ button:disabled { flex-shrink: 0; } -#send::after { content: "➤"; font-weight: bold; } -#send.loading::after { content: ''; } -#send.loading { animation: spin 1s linear infinite; } -@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } +#send::after { + content: "➤"; + font-weight: bold; +} -#logout { background: linear-gradient(135deg, #6b7280, #4b5563); border-radius: 12px; } +#send.loading::after { + content: ''; +} -.auth-buttons, .logout-container { display: flex; gap: 12px; justify-content: center; margin-top: 20px; } -.chat-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; } -.model-select { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--text-secondary); } -.model-select select { background: rgba(15, 15, 35, 0.8); color: var(--text-primary); border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; font-size: 14px; outline: none; transition: all 0.2s ease; } -.model-select select:hover, .model-select select:focus { border-color: var(--accent); background: rgba(15, 15, 35, 1); } +#send.loading { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +#logout { + background: linear-gradient(135deg, #6b7280, #4b5563); + border-radius: 12px; +} + +.auth-buttons, .logout-container { + display: flex; + gap: 12px; + justify-content: center; + margin-top: 20px; +} + +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.chat-title { + display: flex; + align-items: center; + gap: 10px; +} + +.chat-title h2 { + margin: 0; + font-size: 1.3rem; +} + +.icon-button { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 8px 12px; + border-radius: 10px; + font-size: 13px; +} + +.icon-button:hover { + background: rgba(255, 255, 255, 0.15); +} + +.danger { + background: linear-gradient(135deg, #ef4444, #dc2626); +} + +.model-select { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--text-secondary); +} + +.model-select select { + background: rgba(15, 15, 35, 0.8); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px 10px; + font-size: 14px; + outline: none; + transition: all 0.2s ease; +} + +.model-select select:hover, .model-select select:focus { + border-color: var(--accent); + background: rgba(15, 15, 35, 1); +} @media (max-width: 1100px) { - .app-shell { flex-direction: column; align-items: center; } - #chat { width: 95vw; height: 80vh; } + .app-shell { + flex-direction: column; + align-items: center; + } + + #chat { + width: 95vw; + height: 85vh; + } + + .chat-layout { + grid-template-columns: 1fr; + } + + .chat-sidebar { + order: 2; + } + + .chat-main { + order: 1; + } } @media (max-width: 768px) { - body { padding: 16px; } - #chat { width: 95vw; height: 85vh; padding: 16px; } - #auth { width: 95vw; padding: 28px; } - h1 { font-size: 1.6rem; } + body { + padding: 16px; + } + + #chat { + width: 95vw; + height: 85vh; + padding: 16px; + } + + #auth { + width: 95vw; + padding: 28px; + } + + h1 { + font-size: 1.6rem; + } } @media (max-width: 480px) { - .brand { grid-template-columns: 1fr; } - .logo { width: 48px; height: 48px; } - #chat { padding: 12px; } - #messages { padding: 14px; } + .brand { + grid-template-columns: 1fr; + } + + .logo { + width: 48px; + height: 48px; + } + + #chat { + padding: 12px; + } + + #messages { + padding: 14px; + } } .consent-container { diff --git a/server.js b/server.js index 45e612c..d4398b4 100644 --- a/server.js +++ b/server.js @@ -6,7 +6,7 @@ import path from "path"; import bodyParser from "body-parser"; import Database from "better-sqlite3"; import FileStoreFactory from "session-file-store"; -import { exec } from "child_process"; +import {exec} from "child_process"; const modelState = new Map(); const app = express(); @@ -20,7 +20,7 @@ if (!fs.existsSync("./data")) { function scheduleModelUnload(model) { if (!modelState.has(model)) { - modelState.set(model, { timer: null, activeRequests: 0 }); + modelState.set(model, {timer: null, activeRequests: 0}); } const state = modelState.get(model); @@ -47,37 +47,189 @@ function scheduleModelUnload(model) { const db = new Database(DB_PATH); db.pragma("journal_mode = WAL"); +db.pragma("foreign_keys = ON"); db.prepare(` - CREATE TABLE IF NOT EXISTS users ( - username TEXT PRIMARY KEY, - password TEXT NOT NULL + CREATE TABLE IF NOT EXISTS users + ( + username + TEXT + PRIMARY + KEY, + password + TEXT + NOT + NULL ) `).run(); db.prepare(` - CREATE TABLE IF NOT EXISTS chats ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + CREATE TABLE IF NOT EXISTS chats + ( + id + INTEGER + PRIMARY + KEY + AUTOINCREMENT, + username + TEXT + NOT + NULL, + role + TEXT + NOT + NULL, + content + TEXT + NOT + NULL, + created_at + DATETIME + DEFAULT + CURRENT_TIMESTAMP ) `).run(); +db.prepare(` + CREATE TABLE IF NOT EXISTS chat_sessions + ( + id + INTEGER + PRIMARY + KEY + AUTOINCREMENT, + username + TEXT + NOT + NULL, + title + TEXT + NOT + NULL, + created_at + DATETIME + DEFAULT + CURRENT_TIMESTAMP, + updated_at + DATETIME + DEFAULT + CURRENT_TIMESTAMP + ) +`).run(); +db.prepare(` + CREATE TABLE IF NOT EXISTS chat_messages + ( + id + INTEGER + PRIMARY + KEY + AUTOINCREMENT, + chat_id + INTEGER + NOT + NULL, + role + TEXT + NOT + NULL, + content + TEXT + NOT + NULL, + created_at + DATETIME + DEFAULT + CURRENT_TIMESTAMP, + FOREIGN + KEY + ( + chat_id + ) REFERENCES chat_sessions + ( + id + ) ON DELETE CASCADE + ) +`).run(); const insertUserStmt = db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"); const getUserStmt = db.prepare("SELECT username, password FROM users WHERE username = ?"); -const insertChatStmt = db.prepare("INSERT INTO chats (username, role, content) VALUES (?, ?, ?)"); -const getChatsStmt = db.prepare(` - SELECT role, content FROM chats +const getLegacyChatsStmt = db.prepare(` + SELECT role, content + FROM chats WHERE username = ? ORDER BY datetime(created_at) `); -const getRecentChatsStmt = db.prepare(` - SELECT role, content FROM chats +const deleteLegacyChatsStmt = db.prepare("DELETE FROM chats WHERE username = ?"); +const insertChatSessionStmt = db.prepare( + "INSERT INTO chat_sessions (username, title) VALUES (?, ?)" +); +const listChatSessionsStmt = db.prepare(` + SELECT id, title, created_at, updated_at + FROM chat_sessions WHERE username = ? - ORDER BY datetime(created_at) DESC - LIMIT 20 + ORDER BY datetime(updated_at) DESC `); -const deleteChatsStmt = db.prepare("DELETE FROM chats WHERE username = ?"); + +const getChatSessionStmt = db.prepare( + "SELECT id, title FROM chat_sessions WHERE id = ? AND username = ?" +); + +const updateChatSessionTitleStmt = db.prepare( + "UPDATE chat_sessions SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?" +); + +const touchChatSessionStmt = db.prepare( + "UPDATE chat_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?" +); + +const deleteChatSessionStmt = db.prepare( + "DELETE FROM chat_sessions WHERE id = ? AND username = ?" +); + +const deleteAllChatSessionsStmt = db.prepare( + "DELETE FROM chat_sessions WHERE username = ?" +); + +const insertChatMessageStmt = db.prepare( + "INSERT INTO chat_messages (chat_id, role, content) VALUES (?, ?, ?)" +); + +const getChatMessagesStmt = db.prepare(` + SELECT role, content + FROM chat_messages + WHERE chat_id = ? + ORDER BY datetime(created_at) + ` + ) +; +const getRecentChatMessagesStmt = db.prepare(` + SELECT role, content + FROM chat_messages + WHERE chat_id = ? + ORDER BY datetime(created_at) DESC LIMIT 20 + ` + ) +; + +const deleteChatMessagesStmt = db.prepare( + "DELETE FROM chat_messages WHERE chat_id = ?" +); + +const migrationNeeded = db.prepare( + "SELECT COUNT(*) as count FROM chat_messages" +).get(); +const legacyChatsExist = db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chats'" +).get(); +if (legacyChatsExist && migrationNeeded.count === 0) { + const legacyUsers = db.prepare( + "SELECT DISTINCT username FROM chats" + ).all(); + legacyUsers.forEach(({username}) => { + const sessionId = insertChatSessionStmt.run(username, "Imported chat").lastInsertRowid; + const legacyMessages = getLegacyChatsStmt.all(username); + legacyMessages.forEach(msg => { + insertChatMessageStmt.run(sessionId, msg.role, msg.content); + }); + }); +} const FileStore = FileStoreFactory(session); app.use(bodyParser.json()); @@ -131,24 +283,93 @@ app.get("/models", async (req, res) => { res.json(await response.json()); }); +app.get("/chats", requireLogin, (req, res) => { + const user = req.session.user; + const chats = listChatSessionsStmt.all(user); + res.json({chats}); +}); + +app.post("/chats", requireLogin, (req, res) => { + const user = req.session.user; + const title = (req.body?.title || "New chat").trim() || "New chat"; + const chatId = insertChatSessionStmt.run(user, title).lastInsertRowid; + res.json({chat: {id: chatId, title}}); +}); + +app.put("/chats/:id", requireLogin, (req, res) => { + const user = req.session.user; + const chatId = Number(req.params.id); + const title = (req.body?.title || "").trim(); + if (!title) { + return res.status(400).json({error: "Title is required"}); + } + const result = updateChatSessionTitleStmt.run(title, chatId, user); + if (result.changes === 0) { + return res.status(404).json({error: "Chat not found"}); + } + res.json({chat: {id: chatId, title}}); +}); + +app.delete("/chats/:id", requireLogin, (req, res) => { + const user = req.session.user; + const chatId = Number(req.params.id); + const result = deleteChatSessionStmt.run(chatId, user); + if (result.changes === 0) { + return res.status(404).json({error: "Chat not found"}); + } + res.json({message: "Chat deleted"}); +}); + +app.get("/chats/:id/messages", requireLogin, (req, res) => { + const user = req.session.user; + const chatId = Number(req.params.id); + const session = getChatSessionStmt.get(chatId, user); + if (!session) { + return res.status(404).json({error: "Chat not found"}); + } + const messages = getChatMessagesStmt.all(chatId); + res.json({messages}); +}); + +app.post("/chats/:id/clear", requireLogin, (req, res) => { + const user = req.session.user; + const chatId = Number(req.params.id); + const session = getChatSessionStmt.get(chatId, user); + if (!session) { + return res.status(404).json({error: "Chat not found"}); + } + deleteChatMessagesStmt.run(chatId); + touchChatSessionStmt.run(chatId, user); + res.json({message: "Chat cleared"}); +}); + app.post("/chat", requireLogin, async (req, res) => { const user = req.session.user; - const { message, model } = req.body; + const {message, model, chatId} = req.body; const selectedModel = model || "mistral:latest"; + const targetChatId = Number(chatId); + + if (!targetChatId) { + return res.status(400).json({error: "chatId is required"}); + } + + const session = getChatSessionStmt.get(targetChatId, user); + if (!session) { + return res.status(404).json({error: "Chat not found"}); + } if (!modelState.has(selectedModel)) { - modelState.set(selectedModel, { activeRequests: 0, timer: null }); + modelState.set(selectedModel, {activeRequests: 0, timer: null}); } const state = modelState.get(selectedModel); state.activeRequests++; try { - const history = getRecentChatsStmt.all(user); + const history = getRecentChatMessagesStmt.all(targetChatId); const conversation = [...history].reverse(); - conversation.push({ role: "user", content: message }); - - insertChatStmt.run(user, "user", message); - + conversation.push({role: "user", content: message}); + insertChatMessageStmt.run(targetChatId, "user", message); + touchChatSessionStmt.run(targetChatId, user); const messagesPayload = conversation.map(m => ({ role: m.role === "bot" ? "assistant" : "user", content: m.content @@ -156,7 +377,7 @@ app.post("/chat", requireLogin, async (req, res) => { const response = await fetch("https://ai.krishd.ch/api/chat", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: {"Content-Type": "application/json"}, body: JSON.stringify({ model: selectedModel, messages: messagesPayload, @@ -175,10 +396,10 @@ app.post("/chat", requireLogin, async (req, res) => { let fullReply = ""; while (true) { - const { value, done } = await reader.read(); + const {value, done} = await reader.read(); if (done) break; - buffer += decoder.decode(value, { stream: true }); + buffer += decoder.decode(value, {stream: true}); let lines = buffer.split("\n"); buffer = lines.pop(); @@ -197,15 +418,13 @@ app.post("/chat", requireLogin, async (req, res) => { } } } - - insertChatStmt.run(user, "bot", fullReply); + insertChatMessageStmt.run(targetChatId, "bot", fullReply); + touchChatSessionStmt.run(targetChatId, user); console.log("Reply length:", fullReply.length); - - res.json({ reply: fullReply }); - + res.json({reply: fullReply}); } catch (err) { console.error("AI chat error:", err); - res.status(500).json({ error: "AI request failed" }); + res.status(500).json({error: "AI request failed"}); } finally { state.activeRequests--; @@ -222,14 +441,8 @@ app.get("/session", (req, res) => { app.get("/chat/history", requireLogin, (req, res) => { const user = req.session.user; - const history = getChatsStmt.all(user); - res.json({history}); -}); - -app.post("/chat/clear", requireLogin, (req, res) => { - const user = req.session.user; - deleteChatsStmt.run(user); - res.json({message: "Chat cleared"}); + const chats = listChatSessionsStmt.all(user); + res.json({history: chats}); }); app.get("/", (req, res) => {