diff --git a/public/index.html b/public/index.html index 9cb9ce9..5c5b75e 100644 --- a/public/index.html +++ b/public/index.html @@ -75,6 +75,48 @@
+
+ +

No persona equipped.

+
+ +
@@ -106,4 +148,4 @@ - \ No newline at end of file + diff --git a/public/script.js b/public/script.js index 706e989..a174282 100644 --- a/public/script.js +++ b/public/script.js @@ -20,10 +20,25 @@ const newChatBtn = document.getElementById("newChat"); const renameChatBtn = document.getElementById("renameChat"); const deleteChatBtn = document.getElementById("deleteChat"); const activeChatTitle = document.getElementById("activeChatTitle"); +const personaList = document.getElementById("personaList"); +const personaForm = document.getElementById("personaForm"); +const personaFormTitle = document.getElementById("personaFormTitle"); +const personaNameInput = document.getElementById("personaName"); +const personaPronounsInput = document.getElementById("personaPronouns"); +const personaAppearanceInput = document.getElementById("personaAppearance"); +const personaBackgroundInput = document.getElementById("personaBackground"); +const personaDetailsInput = document.getElementById("personaDetails"); +const newPersonaBtn = document.getElementById("newPersona"); +const clearPersonaBtn = document.getElementById("clearPersona"); +const personaCancelBtn = document.getElementById("personaCancel"); +const activePersonaStatus = document.getElementById("activePersonaStatus"); let isProcessing = false; let chatSessions = []; let activeChatId = null; +let personas = []; +let activePersonaId = null; +let editingPersonaId = null; const displayModels = async () => { const select = document.getElementById("model"); @@ -168,6 +183,143 @@ function renderChatList() { }); } +function setActivePersonaStatus() { + const activePersona = personas.find(persona => persona.id === activePersonaId); + if (activePersona) { + activePersonaStatus.textContent = `Equipped: ${activePersona.name}`; + } else { + activePersonaStatus.textContent = 'No persona equipped.'; + } + clearPersonaBtn.disabled = !activePersonaId; +} + +function openPersonaForm(persona = null) { + personaForm.classList.remove('hidden'); + if (persona) { + editingPersonaId = persona.id; + personaFormTitle.textContent = 'Edit persona'; + personaNameInput.value = persona.name || ''; + personaPronounsInput.value = persona.pronouns || ''; + personaAppearanceInput.value = persona.appearance || ''; + personaBackgroundInput.value = persona.background || ''; + personaDetailsInput.value = persona.details || ''; + } else { + editingPersonaId = null; + personaFormTitle.textContent = 'Create persona'; + personaNameInput.value = ''; + personaPronounsInput.value = ''; + personaAppearanceInput.value = ''; + personaBackgroundInput.value = ''; + personaDetailsInput.value = ''; + } + personaNameInput.focus(); +} + +function closePersonaForm() { + personaForm.classList.add('hidden'); + editingPersonaId = null; +} + +function renderPersonaList() { + personaList.innerHTML = ''; + if (!personas.length) { + const empty = document.createElement('p'); + empty.className = 'status persona-status'; + empty.textContent = 'No personas yet. Create one to get started.'; + personaList.appendChild(empty); + setActivePersonaStatus(); + return; + } + personas.forEach(persona => { + const item = document.createElement('div'); + item.className = `persona-item${persona.id === activePersonaId ? ' active' : ''}`; + const title = document.createElement('h4'); + title.textContent = persona.name; + const meta = document.createElement('p'); + meta.textContent = persona.pronouns ? `Pronouns: ${persona.pronouns}` : 'Pronouns: —'; + const actions = document.createElement('div'); + actions.className = 'persona-item-actions'; + + const equipBtn = document.createElement('button'); + equipBtn.type = 'button'; + equipBtn.textContent = persona.id === activePersonaId ? 'Equipped' : 'Equip'; + equipBtn.disabled = persona.id === activePersonaId; + equipBtn.addEventListener('click', () => equipPersona(persona.id)); + + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.textContent = 'Edit'; + editBtn.addEventListener('click', () => openPersonaForm(persona)); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.textContent = 'Delete'; + deleteBtn.addEventListener('click', () => deletePersona(persona.id)); + + actions.appendChild(equipBtn); + actions.appendChild(editBtn); + actions.appendChild(deleteBtn); + + item.appendChild(title); + item.appendChild(meta); + item.appendChild(actions); + personaList.appendChild(item); + }); + setActivePersonaStatus(); +} + +async function loadPersonas() { + const res = await get('/personas'); + if (res.error) return; + personas = res.personas || []; + activePersonaId = res.activePersonaId || null; + renderPersonaList(); +} + +async function equipPersona(personaId) { + const res = await post(`/personas/${personaId}/equip`, {}); + if (res.error) return; + activePersonaId = personaId; + renderPersonaList(); +} + +async function clearPersona() { + const res = await post('/personas/clear', {}); + if (res.error) return; + activePersonaId = null; + renderPersonaList(); +} + +async function savePersona() { + const payload = { + name: personaNameInput.value.trim(), + pronouns: personaPronounsInput.value.trim(), + appearance: personaAppearanceInput.value.trim(), + background: personaBackgroundInput.value.trim(), + details: personaDetailsInput.value.trim() + }; + if (!payload.name) return; + let res; + if (editingPersonaId) { + res = await request(`/personas/${editingPersonaId}`, payload, "PUT"); + } else { + res = await post('/personas', payload); + } + if (res.error) return; + await loadPersonas(); + closePersonaForm(); +} + +async function deletePersona(personaId) { + const persona = personas.find(item => item.id === personaId); + if (!persona) return; + const confirmDelete = confirm(`Delete persona "${persona.name}"?`); + if (!confirmDelete) return; + const res = await request(`/personas/${personaId}`, null, "DELETE"); + if (res.error) return; + await loadPersonas(); +} + function setLoadingState(loading) { isProcessing = loading; sendBtn.disabled = loading; @@ -282,6 +434,7 @@ async function checkSession() { authDiv.style.display = "none"; chatDiv.style.display = "block"; await loadChatSessions(); + await loadPersonas(); msgInput.focus(); return true; } @@ -290,6 +443,7 @@ async function checkSession() { async function loadServerChat() { await loadChatSessions(); + await loadPersonas(); } async function handleAuth(endpoint, credentials) { @@ -396,6 +550,8 @@ document.getElementById("logout").onclick = async () => { clearMessages(); chatSessions = []; activeChatId = null; + personas = []; + activePersonaId = null; setAuthMessage("Logged out.", 'success'); showAuthScreen('login'); loginUsernameInput.value = ""; @@ -424,11 +580,22 @@ deleteChatBtn.addEventListener('click', () => { deleteChat(activeChatId); } }); -+ - msgInput.addEventListener('input', function () { - this.style.height = 'auto'; - this.style.height = Math.min(this.scrollHeight, 120) + 'px'; - }); + +msgInput.addEventListener('input', function () { + this.style.height = 'auto'; + this.style.height = Math.min(this.scrollHeight, 120) + 'px'; +}); + +newPersonaBtn.addEventListener('click', () => openPersonaForm()); + +personaCancelBtn.addEventListener('click', () => closePersonaForm()); + +clearPersonaBtn.addEventListener('click', () => clearPersona()); + +personaForm.addEventListener('submit', (event) => { + event.preventDefault(); + savePersona(); +}); window.addEventListener('load', () => { checkSession(); diff --git a/public/style.css b/public/style.css index 81b18b8..1083397 100644 --- a/public/style.css +++ b/public/style.css @@ -387,6 +387,101 @@ button:disabled { background: rgba(255, 255, 255, 0.08); } +.persona-panel { + display: flex; + flex-direction: column; + gap: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding-top: 16px; + min-height: 0; +} + +.persona-header { + align-items: flex-start; +} + +.persona-header-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.persona-status { + text-align: left; + margin: 0; + padding: 10px 12px; + font-size: 13px; +} + +.persona-list { + display: grid; + gap: 10px; + max-height: 200px; + overflow-y: auto; + padding-right: 4px; +} + +.persona-item { + background: rgba(15, 15, 35, 0.7); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + padding: 10px 12px; + display: grid; + gap: 8px; +} + +.persona-item.active { + border-color: rgba(99, 102, 241, 0.6); + box-shadow: 0 8px 18px rgba(99, 102, 241, 0.18); +} + +.persona-item h4 { + margin: 0; + font-size: 0.98rem; +} + +.persona-item p { + margin: 0; + font-size: 0.82rem; + color: var(--text-secondary); +} + +.persona-item-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.persona-item-actions button { + padding: 6px 10px; + font-size: 12px; + border-radius: 10px; +} + +.persona-form { + display: grid; + gap: 10px; + background: rgba(10, 10, 20, 0.75); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 16px; + padding: 12px; +} + +.persona-form.hidden { + display: none; +} + +.persona-form h3 { + margin: 0; + font-size: 1rem; +} + +.persona-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + .chat-main { display: grid; grid-template-rows: auto 1fr auto auto; diff --git a/server.js b/server.js index f4fdb10..fc20da6 100644 --- a/server.js +++ b/server.js @@ -147,6 +147,59 @@ db.prepare(` ) ON DELETE CASCADE ) `).run(); +db.prepare(` + CREATE TABLE IF NOT EXISTS personas + ( + id + INTEGER + PRIMARY + KEY + AUTOINCREMENT, + username + TEXT + NOT + NULL, + name + TEXT + NOT + NULL, + pronouns + TEXT, + appearance + TEXT, + background + TEXT, + details + TEXT, + created_at + DATETIME + DEFAULT + CURRENT_TIMESTAMP, + updated_at + DATETIME + DEFAULT + CURRENT_TIMESTAMP + ) +`).run(); +db.prepare(` + CREATE TABLE IF NOT EXISTS user_settings + ( + username + TEXT + PRIMARY + KEY, + active_persona_id + INTEGER, + FOREIGN + KEY + ( + active_persona_id + ) REFERENCES personas + ( + id + ) ON DELETE SET NULL + ) +`).run(); const insertUserStmt = db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"); const getUserStmt = db.prepare("SELECT username, password FROM users WHERE username = ?"); @@ -218,6 +271,50 @@ const deleteChatMessagesStmt = db.prepare( "DELETE FROM chat_messages WHERE chat_id = ?" ); +const listPersonasStmt = db.prepare(` + SELECT id, name, pronouns, appearance, background, details, created_at, updated_at + FROM personas + WHERE username = ? + ORDER BY datetime(updated_at) DESC +`); + +const getPersonaStmt = db.prepare(` + SELECT id, name, pronouns, appearance, background, details + FROM personas + WHERE id = ? AND username = ? +`); + +const insertPersonaStmt = db.prepare( + "INSERT INTO personas (username, name, pronouns, appearance, background, details) VALUES (?, ?, ?, ?, ?, ?)" +); + +const updatePersonaStmt = db.prepare(` + UPDATE personas + SET name = ?, pronouns = ?, appearance = ?, background = ?, details = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND username = ? +`); + +const deletePersonaStmt = db.prepare( + "DELETE FROM personas WHERE id = ? AND username = ?" +); + +const getActivePersonaIdStmt = db.prepare( + "SELECT active_persona_id FROM user_settings WHERE username = ?" +); + +const setActivePersonaStmt = db.prepare(` + INSERT INTO user_settings (username, active_persona_id) + VALUES (?, ?) + ON CONFLICT(username) DO UPDATE SET active_persona_id = excluded.active_persona_id +`); + +const getActivePersonaStmt = db.prepare(` + SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details + FROM user_settings us + JOIN personas p ON p.id = us.active_persona_id + WHERE us.username = ? +`); + const migrationNeeded = db.prepare( "SELECT COUNT(*) as count FROM chat_messages" ).get(); @@ -279,14 +376,38 @@ app.post("/logout", (req, res) => { req.session.destroy(() => res.json({message: "Logged out"})); }); +const normalizePersonaField = (value) => { + const trimmed = (value || "").trim(); + return trimmed ? trimmed : null; +}; + +const buildPersonaPrompt = (persona) => { + const lines = [ + "You are roleplaying with the following persona. Stay in character, be engaging, and align with these details." + ]; + if (persona.name) lines.push(`Name: ${persona.name}`); + if (persona.pronouns) lines.push(`Pronouns: ${persona.pronouns}`); + if (persona.appearance) lines.push(`Appearance: ${persona.appearance}`); + if (persona.background) lines.push(`Background: ${persona.background}`); + if (persona.details) lines.push(`Details: ${persona.details}`); + return lines.join("\n"); +}; + const requireLogin = (req, res, next) => { if (!req.session.user) return res.status(403).json({error: "Not logged in"}); next(); }; app.get("/models", async (req, res) => { - const response = await fetch("https://ai.krishd.ch/api/tags"); - res.json(await response.json()); + try { + const response = await fetch("https://ai.krishd.ch/api/tags"); + if (!response.ok) { + return res.status(500).json({error: "Failed to load models"}); + } + res.json(await response.json()); + } catch (error) { + res.status(500).json({error: "Failed to load models"}); + } }); app.get("/chats", requireLogin, (req, res) => { @@ -355,6 +476,93 @@ app.post("/chats/:id/clear", requireLogin, (req, res) => { res.json({message: "Chat cleared"}); }); +app.get("/personas", requireLogin, (req, res) => { + const user = req.session.user; + const personas = listPersonasStmt.all(user); + const activePersonaId = getActivePersonaIdStmt.get(user)?.active_persona_id ?? null; + res.json({personas, activePersonaId}); +}); + +app.post("/personas", requireLogin, (req, res) => { + const user = req.session.user; + const name = (req.body?.name || "").trim(); + if (!name) { + return res.status(400).json({error: "Name is required"}); + } + const pronouns = normalizePersonaField(req.body?.pronouns); + const appearance = normalizePersonaField(req.body?.appearance); + const background = normalizePersonaField(req.body?.background); + const details = normalizePersonaField(req.body?.details); + const personaId = insertPersonaStmt.run( + user, + name, + pronouns, + appearance, + background, + details + ).lastInsertRowid; + const persona = getPersonaStmt.get(personaId, user); + res.json({persona}); +}); + +app.put("/personas/:id", requireLogin, (req, res) => { + const user = req.session.user; + const personaId = Number(req.params.id); + const name = (req.body?.name || "").trim(); + if (!name) { + return res.status(400).json({error: "Name is required"}); + } + const pronouns = normalizePersonaField(req.body?.pronouns); + const appearance = normalizePersonaField(req.body?.appearance); + const background = normalizePersonaField(req.body?.background); + const details = normalizePersonaField(req.body?.details); + const result = updatePersonaStmt.run( + name, + pronouns, + appearance, + background, + details, + personaId, + user + ); + if (result.changes === 0) { + return res.status(404).json({error: "Persona not found"}); + } + const persona = getPersonaStmt.get(personaId, user); + res.json({persona}); +}); + +app.delete("/personas/:id", requireLogin, (req, res) => { + const user = req.session.user; + const personaId = Number(req.params.id); + const activePersonaId = getActivePersonaIdStmt.get(user)?.active_persona_id ?? null; + const result = deletePersonaStmt.run(personaId, user); + if (result.changes === 0) { + return res.status(404).json({error: "Persona not found"}); + } + if (activePersonaId === personaId) { + setActivePersonaStmt.run(user, null); + } + res.json({message: "Persona deleted"}); +}); + +app.post("/personas/:id/equip", requireLogin, (req, res) => { + const user = req.session.user; + const personaId = Number(req.params.id); + const persona = getPersonaStmt.get(personaId, user); + if (!persona) { + return res.status(404).json({error: "Persona not found"}); + } + setActivePersonaStmt.run(user, personaId); + res.json({activePersonaId: personaId}); +}); + +app.post("/personas/clear", requireLogin, (req, res) => { + const user = req.session.user; + setActivePersonaStmt.run(user, null); + res.json({activePersonaId: null}); +}); + app.post("/chat", requireLogin, async (req, res) => { const user = req.session.user; const {message, model, chatId} = req.body; @@ -377,15 +585,25 @@ app.post("/chat", requireLogin, async (req, res) => { state.activeRequests++; try { + const activePersona = getActivePersonaStmt.get(user); const history = getRecentChatMessagesStmt.all(targetChatId); const conversation = [...history].reverse(); 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 - })); + const messagesPayload = []; + if (activePersona) { + messagesPayload.push({ + role: "system", + content: buildPersonaPrompt(activePersona) + }); + } + messagesPayload.push( + ...conversation.map(m => ({ + role: m.role === "bot" ? "assistant" : "user", + content: m.content + })) + ); const response = await fetch("https://ai.krishd.ch/api/chat", { method: "POST",