From 2cd6c9c4d704b4ab2bac4ce2f8dc5a8ca9349a7e Mon Sep 17 00:00:00 2001 From: Fretux Date: Wed, 7 Jan 2026 21:06:04 +0100 Subject: [PATCH] Move persona editor into modal --- public/index.html | 58 +++++++++- public/script.js | 247 +++++++++++++++++++++++++++++++++++++++- public/style.css | 167 +++++++++++++++++++++++++++ server.js | 280 +++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 740 insertions(+), 12 deletions(-) diff --git a/public/index.html b/public/index.html index 9cb9ce9..079f303 100644 --- a/public/index.html +++ b/public/index.html @@ -75,6 +75,20 @@
+
+ +

No persona equipped.

+
+
@@ -102,8 +116,50 @@
+ - \ No newline at end of file + diff --git a/public/script.js b/public/script.js index 706e989..d193919 100644 --- a/public/script.js +++ b/public/script.js @@ -20,10 +20,29 @@ 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 personaModal = document.getElementById("personaModal"); +const personaCloseBtn = document.getElementById("personaClose"); +const addCustomFieldBtn = document.getElementById("addCustomField"); +const customFieldsList = document.getElementById("customFieldsList"); +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 +187,199 @@ 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) { + personaModal.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 || ''; + renderCustomFields(persona.customFields || []); + } else { + editingPersonaId = null; + personaFormTitle.textContent = 'Create persona'; + personaNameInput.value = ''; + personaPronounsInput.value = ''; + personaAppearanceInput.value = ''; + personaBackgroundInput.value = ''; + personaDetailsInput.value = ''; + renderCustomFields([]); + } + personaNameInput.focus(); +} + +function closePersonaForm() { + personaModal.classList.add('hidden'); + editingPersonaId = null; +} + +function renderCustomFields(fields) { + customFieldsList.innerHTML = ''; + fields.forEach((field, index) => { + addCustomFieldRow(field.label || '', field.value || '', index); + }); +} + +function addCustomFieldRow(label = '', value = '') { + const row = document.createElement('div'); + row.className = 'custom-field-row'; + + const labelInput = document.createElement('input'); + labelInput.type = 'text'; + labelInput.placeholder = 'Label'; + labelInput.value = label; + + const valueInput = document.createElement('input'); + valueInput.type = 'text'; + valueInput.placeholder = 'Value'; + valueInput.value = value; + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.textContent = 'Remove'; + removeBtn.className = 'secondary-action'; + removeBtn.addEventListener('click', () => row.remove()); + + row.appendChild(labelInput); + row.appendChild(valueInput); + row.appendChild(removeBtn); + customFieldsList.appendChild(row); +} + +function collectCustomFields() { + const rows = customFieldsList.querySelectorAll('.custom-field-row'); + return Array.from(rows).map(row => { + const [labelInput, valueInput] = row.querySelectorAll('input'); + return { + label: labelInput.value.trim(), + value: valueInput.value.trim() + }; + }).filter(field => field.label || field.value); +} + +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 || []).map(persona => ({ + ...persona, + customFields: persona.customFields || [] + })); + 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(), + customFields: collectCustomFields() + }; + 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(); +} + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !personaModal.classList.contains('hidden')) { + closePersonaForm(); + } +}); + function setLoadingState(loading) { isProcessing = loading; sendBtn.disabled = loading; @@ -282,6 +494,7 @@ async function checkSession() { authDiv.style.display = "none"; chatDiv.style.display = "block"; await loadChatSessions(); + await loadPersonas(); msgInput.focus(); return true; } @@ -290,6 +503,7 @@ async function checkSession() { async function loadServerChat() { await loadChatSessions(); + await loadPersonas(); } async function handleAuth(endpoint, credentials) { @@ -396,6 +610,8 @@ document.getElementById("logout").onclick = async () => { clearMessages(); chatSessions = []; activeChatId = null; + personas = []; + activePersonaId = null; setAuthMessage("Logged out.", 'success'); showAuthScreen('login'); loginUsernameInput.value = ""; @@ -424,11 +640,32 @@ 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()); + +personaCloseBtn.addEventListener('click', () => closePersonaForm()); + +personaModal.addEventListener('click', (event) => { + if (event.target === personaModal) { + closePersonaForm(); + } +}); + +addCustomFieldBtn.addEventListener('click', () => addCustomFieldRow()); + +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..45705bc 100644 --- a/public/style.css +++ b/public/style.css @@ -387,6 +387,173 @@ 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; + max-height: 65vh; + overflow-y: auto; + padding-right: 6px; +} + +.persona-form h3 { + margin: 0; + font-size: 1rem; +} + +.persona-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(5, 5, 20, 0.75); + display: grid; + place-items: center; + z-index: 1000; + padding: 24px; +} + +.modal-overlay.hidden { + display: none; +} + +.modal-card { + width: min(520px, 92vw); + background: rgba(19, 19, 42, 0.96); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 22px; + padding: 18px; + box-shadow: 0 25px 60px -12px var(--shadow); + display: grid; + gap: 14px; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.modal-header h3 { + margin: 0; + font-size: 1.2rem; +} + +.custom-fields { + display: grid; + gap: 10px; + background: rgba(10, 10, 20, 0.6); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + padding: 10px; +} + +.custom-fields-header { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.custom-fields-list { + display: grid; + gap: 8px; +} + +.custom-field-row { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 8px; + align-items: center; +} + +.custom-field-row input { + padding: 10px 12px; + font-size: 14px; +} + +.custom-field-row button { + padding: 8px 10px; + font-size: 12px; + border-radius: 10px; +} + .chat-main { display: grid; grid-template-rows: auto 1fr auto auto; diff --git a/server.js b/server.js index f4fdb10..e857d43 100644 --- a/server.js +++ b/server.js @@ -147,6 +147,61 @@ 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, + custom_fields + 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 +273,50 @@ const deleteChatMessagesStmt = db.prepare( "DELETE FROM chat_messages WHERE chat_id = ?" ); +const listPersonasStmt = db.prepare(` + SELECT id, name, pronouns, appearance, background, details, custom_fields, 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, custom_fields + FROM personas + WHERE id = ? AND username = ? +`); + +const insertPersonaStmt = db.prepare( + "INSERT INTO personas (username, name, pronouns, appearance, background, details, custom_fields) VALUES (?, ?, ?, ?, ?, ?, ?)" +); + +const updatePersonaStmt = db.prepare(` + UPDATE personas + SET name = ?, pronouns = ?, appearance = ?, background = ?, details = ?, custom_fields = ?, 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, p.custom_fields + 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(); @@ -237,6 +336,12 @@ if (legacyChatsExist && migrationNeeded.count === 0) { }); } +const personaColumns = db.prepare("PRAGMA table_info(personas)").all(); +const hasCustomFields = personaColumns.some(column => column.name === "custom_fields"); +if (!hasCustomFields) { + db.prepare("ALTER TABLE personas ADD COLUMN custom_fields TEXT").run(); +} + const FileStore = FileStoreFactory(session); app.use(bodyParser.json()); app.use(express.static("public")); @@ -279,14 +384,63 @@ 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 normalizeCustomFields = (fields) => { + if (!Array.isArray(fields)) return null; + const cleaned = fields + .map(field => ({ + label: (field?.label || "").trim(), + value: (field?.value || "").trim() + })) + .filter(field => field.label || field.value); + return cleaned.length ? JSON.stringify(cleaned) : null; +}; + +const parseCustomFields = (value) => { + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + return []; + } +}; + +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}`); + const customFields = parseCustomFields(persona.custom_fields); + customFields.forEach(field => { + lines.push(`${field.label || "Custom"}: ${field.value}`); + }); + 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 +509,110 @@ 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).map(persona => ({ + ...persona, + customFields: parseCustomFields(persona.custom_fields) + })); + 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 customFields = normalizeCustomFields(req.body?.customFields); + const personaId = insertPersonaStmt.run( + user, + name, + pronouns, + appearance, + background, + details, + customFields + ).lastInsertRowid; + const persona = getPersonaStmt.get(personaId, user); + res.json({ + persona: { + ...persona, + customFields: parseCustomFields(persona.custom_fields) + } + }); +}); + +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 customFields = normalizeCustomFields(req.body?.customFields); + const result = updatePersonaStmt.run( + name, + pronouns, + appearance, + background, + details, + customFields, + personaId, + user + ); + if (result.changes === 0) { + return res.status(404).json({error: "Persona not found"}); + } + const persona = getPersonaStmt.get(personaId, user); + res.json({ + persona: { + ...persona, + customFields: parseCustomFields(persona.custom_fields) + } + }); +}); + +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 +635,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",