Add user personas and move persona controls to popover

This commit is contained in:
2026-01-13 15:22:44 +01:00
parent 2edda0c5f5
commit f5c205a6a3
4 changed files with 334 additions and 85 deletions
+50 -19
View File
@@ -75,20 +75,6 @@
<button id="newChat" class="secondary-action">New</button> <button id="newChat" class="secondary-action">New</button>
</div> </div>
<div id="chatList" class="chat-list"></div> <div id="chatList" class="chat-list"></div>
<div class="persona-panel">
<div class="sidebar-header persona-header">
<div>
<p class="eyebrow">Roleplay</p>
<h2>Personas</h2>
</div>
<div class="persona-header-actions">
<button id="newPersona" class="secondary-action" type="button">New</button>
<button id="clearPersona" class="secondary-action" type="button">Clear</button>
</div>
</div>
<p id="activePersonaStatus" class="status persona-status">No persona equipped.</p>
<div id="personaList" class="persona-list"></div>
</div>
</aside> </aside>
<section class="chat-main"> <section class="chat-main">
<div class="chat-header"> <div class="chat-header">
@@ -96,11 +82,49 @@
<h2 id="activeChatTitle">New chat</h2> <h2 id="activeChatTitle">New chat</h2>
<button id="renameChat" class="icon-button" type="button">Rename</button> <button id="renameChat" class="icon-button" type="button">Rename</button>
</div> </div>
<div class="model-select"> <div class="chat-header-actions">
<label for="model">Model:</label> <div class="model-select">
<select id="model"> <label for="model">Model:</label>
<option disabled selected>Loading models...</option> <select id="model">
</select> <option disabled selected>Loading models...</option>
</select>
</div>
<div class="persona-menu">
<button id="personaMenuButton" class="secondary-action" type="button"
aria-expanded="false" aria-controls="personaPopover">
Personas
</button>
<div id="personaPopover" class="persona-popover hidden" role="dialog">
<div class="persona-section">
<div class="persona-section-header">
<div>
<p class="eyebrow">Roleplay</p>
<h2>AI Persona</h2>
</div>
<div class="persona-section-actions">
<button id="newPersona" class="secondary-action" type="button">New AI</button>
<button id="clearPersona" class="secondary-action" type="button">Clear</button>
</div>
</div>
<p id="activePersonaStatus" class="status persona-status">No persona equipped.</p>
<div id="personaList" class="persona-list"></div>
</div>
<div class="persona-section">
<div class="persona-section-header">
<div>
<p class="eyebrow">Roleplay</p>
<h2>Your Persona</h2>
</div>
<div class="persona-section-actions">
<button id="newUserPersona" class="secondary-action" type="button">New you</button>
<button id="clearUserPersona" class="secondary-action" type="button">Clear</button>
</div>
</div>
<p id="activeUserPersonaStatus" class="status persona-status">No user persona set.</p>
<div id="userPersonaList" class="persona-list"></div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div id="messages"></div> <div id="messages"></div>
@@ -123,6 +147,13 @@
<button id="personaClose" type="button" class="icon-button">Cancel</button> <button id="personaClose" type="button" class="icon-button">Cancel</button>
</div> </div>
<form id="personaForm" class="persona-form"> <form id="personaForm" class="persona-form">
<label class="field">
<span>Persona for</span>
<select id="personaType">
<option value="assistant">AI persona</option>
<option value="user">Your persona</option>
</select>
</label>
<label class="field"> <label class="field">
<span>Name</span> <span>Name</span>
<input id="personaName" placeholder="Persona name" required/> <input id="personaName" placeholder="Persona name" required/>
+126 -32
View File
@@ -21,26 +21,36 @@ const renameChatBtn = document.getElementById("renameChat");
const deleteChatBtn = document.getElementById("deleteChat"); const deleteChatBtn = document.getElementById("deleteChat");
const activeChatTitle = document.getElementById("activeChatTitle"); const activeChatTitle = document.getElementById("activeChatTitle");
const personaList = document.getElementById("personaList"); const personaList = document.getElementById("personaList");
const userPersonaList = document.getElementById("userPersonaList");
const personaForm = document.getElementById("personaForm"); const personaForm = document.getElementById("personaForm");
const personaFormTitle = document.getElementById("personaFormTitle"); const personaFormTitle = document.getElementById("personaFormTitle");
const personaTypeSelect = document.getElementById("personaType");
const personaNameInput = document.getElementById("personaName"); const personaNameInput = document.getElementById("personaName");
const personaPronounsInput = document.getElementById("personaPronouns"); const personaPronounsInput = document.getElementById("personaPronouns");
const personaAppearanceInput = document.getElementById("personaAppearance"); const personaAppearanceInput = document.getElementById("personaAppearance");
const personaBackgroundInput = document.getElementById("personaBackground"); const personaBackgroundInput = document.getElementById("personaBackground");
const personaDetailsInput = document.getElementById("personaDetails"); const personaDetailsInput = document.getElementById("personaDetails");
const newPersonaBtn = document.getElementById("newPersona"); const newPersonaBtn = document.getElementById("newPersona");
const newUserPersonaBtn = document.getElementById("newUserPersona");
const clearPersonaBtn = document.getElementById("clearPersona"); const clearPersonaBtn = document.getElementById("clearPersona");
const clearUserPersonaBtn = document.getElementById("clearUserPersona");
const personaCancelBtn = document.getElementById("personaCancel"); const personaCancelBtn = document.getElementById("personaCancel");
const activePersonaStatus = document.getElementById("activePersonaStatus"); const activePersonaStatus = document.getElementById("activePersonaStatus");
const activeUserPersonaStatus = document.getElementById("activeUserPersonaStatus");
const personaModal = document.getElementById("personaModal"); const personaModal = document.getElementById("personaModal");
const personaCloseBtn = document.getElementById("personaClose"); const personaCloseBtn = document.getElementById("personaClose");
const personaMenuButton = document.getElementById("personaMenuButton");
const personaPopover = document.getElementById("personaPopover");
let isProcessing = false; let isProcessing = false;
let chatSessions = []; let chatSessions = [];
let activeChatId = null; let activeChatId = null;
let personas = []; let assistantPersonas = [];
let userPersonas = [];
let activePersonaId = null; let activePersonaId = null;
let activeUserPersonaId = null;
let editingPersonaId = null; let editingPersonaId = null;
let editingPersonaType = "assistant";
const displayModels = async () => { const displayModels = async () => {
const select = document.getElementById("model"); const select = document.getElementById("model");
@@ -185,21 +195,43 @@ function renderChatList() {
}); });
} }
function setActivePersonaStatus() { function updateActivePersonaStatus(personaItems, activeId, statusElement, emptyMessage, activePrefix) {
const activePersona = personas.find(persona => persona.id === activePersonaId); const activePersona = personaItems.find(persona => persona.id === activeId);
if (activePersona) { if (activePersona) {
activePersonaStatus.textContent = `Equipped: ${activePersona.name}`; statusElement.textContent = `${activePrefix}: ${activePersona.name}`;
} else { } else {
activePersonaStatus.textContent = 'No persona equipped.'; statusElement.textContent = emptyMessage;
} }
clearPersonaBtn.disabled = !activePersonaId;
} }
function openPersonaForm(persona = null) { function setActivePersonaStatus() {
updateActivePersonaStatus(
assistantPersonas,
activePersonaId,
activePersonaStatus,
'No persona equipped.',
'AI equipped'
);
updateActivePersonaStatus(
userPersonas,
activeUserPersonaId,
activeUserPersonaStatus,
'No user persona set.',
'You are'
);
clearPersonaBtn.disabled = !activePersonaId;
clearUserPersonaBtn.disabled = !activeUserPersonaId;
}
function openPersonaForm(persona = null, personaType = "assistant") {
closePersonaPopover();
personaModal.classList.remove("hidden"); personaModal.classList.remove("hidden");
if (persona) { if (persona) {
editingPersonaId = persona.id; editingPersonaId = persona.id;
personaFormTitle.textContent = "Edit persona"; editingPersonaType = persona.persona_type || "assistant";
personaFormTitle.textContent = `Edit ${editingPersonaType === "user" ? "your persona" : "AI persona"}`;
personaTypeSelect.value = editingPersonaType;
personaTypeSelect.disabled = true;
personaNameInput.value = persona.name || ""; personaNameInput.value = persona.name || "";
personaPronounsInput.value = persona.pronouns || ""; personaPronounsInput.value = persona.pronouns || "";
personaAppearanceInput.value = persona.appearance || ""; personaAppearanceInput.value = persona.appearance || "";
@@ -207,7 +239,10 @@ function openPersonaForm(persona = null) {
personaDetailsInput.value = persona.details || ""; personaDetailsInput.value = persona.details || "";
} else { } else {
editingPersonaId = null; editingPersonaId = null;
personaFormTitle.textContent = "Create persona"; editingPersonaType = personaType;
personaFormTitle.textContent = `Create ${personaType === "user" ? "your persona" : "AI persona"}`;
personaTypeSelect.value = personaType;
personaTypeSelect.disabled = false;
personaNameInput.value = ""; personaNameInput.value = "";
personaPronounsInput.value = ""; personaPronounsInput.value = "";
personaAppearanceInput.value = ""; personaAppearanceInput.value = "";
@@ -221,21 +256,23 @@ function openPersonaForm(persona = null) {
function closePersonaForm() { function closePersonaForm() {
personaModal.classList.add("hidden"); personaModal.classList.add("hidden");
editingPersonaId = null; editingPersonaId = null;
editingPersonaType = "assistant";
personaTypeSelect.disabled = false;
} }
function renderPersonaList() { function renderPersonaList(personaItems, activeId, listElement, personaType) {
personaList.innerHTML = ''; listElement.innerHTML = '';
if (!personas.length) { if (!personaItems.length) {
const empty = document.createElement('p'); const empty = document.createElement('p');
empty.className = 'status persona-status'; empty.className = 'status persona-status';
empty.textContent = 'No personas yet. Create one to get started.'; empty.textContent = 'No personas yet. Create one to get started.';
personaList.appendChild(empty); listElement.appendChild(empty);
setActivePersonaStatus(); setActivePersonaStatus();
return; return;
} }
personas.forEach(persona => { personaItems.forEach(persona => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = `persona-item${persona.id === activePersonaId ? ' active' : ''}`; item.className = `persona-item${persona.id === activeId ? ' active' : ''}`;
const title = document.createElement('h4'); const title = document.createElement('h4');
title.textContent = persona.name; title.textContent = persona.name;
const meta = document.createElement('p'); const meta = document.createElement('p');
@@ -245,9 +282,9 @@ function renderPersonaList() {
const equipBtn = document.createElement('button'); const equipBtn = document.createElement('button');
equipBtn.type = 'button'; equipBtn.type = 'button';
equipBtn.textContent = persona.id === activePersonaId ? 'Equipped' : 'Equip'; equipBtn.textContent = persona.id === activeId ? 'Equipped' : 'Equip';
equipBtn.disabled = persona.id === activePersonaId; equipBtn.disabled = persona.id === activeId;
equipBtn.addEventListener('click', () => equipPersona(persona.id)); equipBtn.addEventListener('click', () => equipPersona(persona.id, personaType));
const editBtn = document.createElement('button'); const editBtn = document.createElement('button');
editBtn.type = 'button'; editBtn.type = 'button';
@@ -266,7 +303,7 @@ function renderPersonaList() {
item.appendChild(title); item.appendChild(title);
item.appendChild(meta); item.appendChild(meta);
item.appendChild(actions); item.appendChild(actions);
personaList.appendChild(item); listElement.appendChild(item);
}); });
setActivePersonaStatus(); setActivePersonaStatus();
} }
@@ -274,27 +311,45 @@ function renderPersonaList() {
async function loadPersonas() { async function loadPersonas() {
const res = await get('/personas'); const res = await get('/personas');
if (res.error) return; if (res.error) return;
personas = res.personas || []; assistantPersonas = res.assistantPersonas || [];
userPersonas = res.userPersonas || [];
activePersonaId = res.activePersonaId || null; activePersonaId = res.activePersonaId || null;
renderPersonaList(); activeUserPersonaId = res.activeUserPersonaId || null;
renderPersonaList(assistantPersonas, activePersonaId, personaList, "assistant");
renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user");
} }
async function equipPersona(personaId) { async function equipPersona(personaId, personaType) {
const res = await post(`/personas/${personaId}/equip`, {}); const endpoint = personaType === "user"
? `/personas/${personaId}/equip-user`
: `/personas/${personaId}/equip`;
const res = await post(endpoint, {});
if (res.error) return; if (res.error) return;
activePersonaId = personaId; if (personaType === "user") {
renderPersonaList(); activeUserPersonaId = personaId;
} else {
activePersonaId = personaId;
}
renderPersonaList(assistantPersonas, activePersonaId, personaList, "assistant");
renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user");
} }
async function clearPersona() { async function clearPersona(personaType) {
const res = await post('/personas/clear', {}); const endpoint = personaType === "user" ? '/personas/user/clear' : '/personas/clear';
const res = await post(endpoint, {});
if (res.error) return; if (res.error) return;
activePersonaId = null; if (personaType === "user") {
renderPersonaList(); activeUserPersonaId = null;
} else {
activePersonaId = null;
}
renderPersonaList(assistantPersonas, activePersonaId, personaList, "assistant");
renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user");
} }
async function savePersona() { async function savePersona() {
const payload = { const payload = {
personaType: personaTypeSelect.value,
name: personaNameInput.value.trim(), name: personaNameInput.value.trim(),
pronouns: personaPronounsInput.value.trim(), pronouns: personaPronounsInput.value.trim(),
appearance: personaAppearanceInput.value.trim(), appearance: personaAppearanceInput.value.trim(),
@@ -314,7 +369,8 @@ async function savePersona() {
} }
async function deletePersona(personaId) { async function deletePersona(personaId) {
const persona = personas.find(item => item.id === personaId); const persona = assistantPersonas.find(item => item.id === personaId)
|| userPersonas.find(item => item.id === personaId);
if (!persona) return; if (!persona) return;
const confirmDelete = confirm(`Delete persona "${persona.name}"?`); const confirmDelete = confirm(`Delete persona "${persona.name}"?`);
if (!confirmDelete) return; if (!confirmDelete) return;
@@ -553,8 +609,10 @@ document.getElementById("logout").onclick = async () => {
clearMessages(); clearMessages();
chatSessions = []; chatSessions = [];
activeChatId = null; activeChatId = null;
personas = []; assistantPersonas = [];
userPersonas = [];
activePersonaId = null; activePersonaId = null;
activeUserPersonaId = null;
setAuthMessage("Logged out.", 'success'); setAuthMessage("Logged out.", 'success');
showAuthScreen('login'); showAuthScreen('login');
loginUsernameInput.value = ""; loginUsernameInput.value = "";
@@ -591,7 +649,11 @@ msgInput.addEventListener('input', function () {
newPersonaBtn.addEventListener('click', () => openPersonaForm()); newPersonaBtn.addEventListener('click', () => openPersonaForm());
clearPersonaBtn.addEventListener('click', () => clearPersona()); newUserPersonaBtn.addEventListener('click', () => openPersonaForm(null, "user"));
clearPersonaBtn.addEventListener('click', () => clearPersona("assistant"));
clearUserPersonaBtn.addEventListener('click', () => clearPersona("user"));
personaForm.addEventListener('submit', (event) => { personaForm.addEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
@@ -604,6 +666,38 @@ personaModal.addEventListener("click", (e) => {
if (e.target === personaModal) closePersonaForm(); if (e.target === personaModal) closePersonaForm();
}); });
function closePersonaPopover() {
personaPopover.classList.add("hidden");
personaMenuButton.setAttribute("aria-expanded", "false");
}
function togglePersonaPopover(event) {
event.stopPropagation();
const isHidden = personaPopover.classList.contains("hidden");
if (isHidden) {
personaPopover.classList.remove("hidden");
personaMenuButton.setAttribute("aria-expanded", "true");
} else {
closePersonaPopover();
}
}
personaMenuButton.addEventListener("click", togglePersonaPopover);
personaPopover.addEventListener("click", (event) => {
event.stopPropagation();
});
document.addEventListener("click", () => {
closePersonaPopover();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closePersonaPopover();
}
});
window.addEventListener('load', () => { window.addEventListener('load', () => {
checkSession(); checkSession();
displayModels(); displayModels();
+58 -21
View File
@@ -176,7 +176,7 @@ h1 {
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
input, textarea { input, textarea, select {
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 14px 16px; padding: 14px 16px;
@@ -196,7 +196,7 @@ input::placeholder, textarea::placeholder {
color: var(--text-muted); color: var(--text-muted);
} }
input:focus, textarea:focus { input:focus, textarea:focus, select:focus {
border-color: var(--accent); border-color: var(--accent);
background: rgba(10, 10, 20, 1); background: rgba(10, 10, 20, 1);
transform: translateY(-1px); transform: translateY(-1px);
@@ -387,25 +387,6 @@ button:disabled {
background: rgba(255, 255, 255, 0.08); 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 { .persona-status {
text-align: left; text-align: left;
margin: 0; margin: 0;
@@ -763,6 +744,14 @@ button:disabled {
gap: 12px; gap: 12px;
} }
.chat-header-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.chat-title { .chat-title {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -809,6 +798,54 @@ button:disabled {
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.persona-menu {
position: relative;
}
.persona-popover {
position: absolute;
right: 0;
top: calc(100% + 10px);
width: min(520px, 80vw);
background: rgba(19, 19, 42, 0.98);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
box-shadow: 0 24px 50px -18px rgba(0, 0, 0, 0.55);
padding: 14px;
display: grid;
gap: 16px;
z-index: 30;
}
.persona-section {
display: grid;
gap: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 12px;
}
.persona-section:first-child {
border-top: none;
padding-top: 0;
}
.persona-section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.persona-section-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.persona-popover .persona-list {
max-height: 220px;
}
.model-select select:hover, .model-select select:focus { .model-select select:hover, .model-select select:focus {
border-color: var(--accent); border-color: var(--accent);
background: rgba(15, 15, 35, 1); background: rgba(15, 15, 35, 1);
+100 -13
View File
@@ -171,6 +171,10 @@ db.prepare(`
TEXT, TEXT,
details details
TEXT, TEXT,
persona_type
TEXT
DEFAULT
'assistant',
created_at created_at
DATETIME DATETIME
DEFAULT DEFAULT
@@ -190,11 +194,21 @@ db.prepare(`
KEY, KEY,
active_persona_id active_persona_id
INTEGER, INTEGER,
active_user_persona_id
INTEGER,
FOREIGN FOREIGN
KEY KEY
( (
active_persona_id active_persona_id
) REFERENCES personas ) REFERENCES personas
(
id
) ON DELETE SET NULL,
FOREIGN
KEY
(
active_user_persona_id
) REFERENCES personas
( (
id id
) ON DELETE SET NULL ) ON DELETE SET NULL
@@ -271,21 +285,21 @@ const deleteChatMessagesStmt = db.prepare(
"DELETE FROM chat_messages WHERE chat_id = ?" "DELETE FROM chat_messages WHERE chat_id = ?"
); );
const listPersonasStmt = db.prepare(` const listPersonasByTypeStmt = db.prepare(`
SELECT id, name, pronouns, appearance, background, details, created_at, updated_at SELECT id, name, pronouns, appearance, background, details, persona_type, created_at, updated_at
FROM personas FROM personas
WHERE username = ? WHERE username = ? AND persona_type = ?
ORDER BY datetime(updated_at) DESC ORDER BY datetime(updated_at) DESC
`); `);
const getPersonaStmt = db.prepare(` const getPersonaStmt = db.prepare(`
SELECT id, name, pronouns, appearance, background, details SELECT id, name, pronouns, appearance, background, details, persona_type
FROM personas FROM personas
WHERE id = ? AND username = ? WHERE id = ? AND username = ?
`); `);
const insertPersonaStmt = db.prepare( const insertPersonaStmt = db.prepare(
"INSERT INTO personas (username, name, pronouns, appearance, background, details) VALUES (?, ?, ?, ?, ?, ?)" "INSERT INTO personas (username, name, pronouns, appearance, background, details, persona_type) VALUES (?, ?, ?, ?, ?, ?, ?)"
); );
const updatePersonaStmt = db.prepare(` const updatePersonaStmt = db.prepare(`
@@ -299,7 +313,7 @@ const deletePersonaStmt = db.prepare(
); );
const getActivePersonaIdStmt = db.prepare( const getActivePersonaIdStmt = db.prepare(
"SELECT active_persona_id FROM user_settings WHERE username = ?" "SELECT active_persona_id, active_user_persona_id FROM user_settings WHERE username = ?"
); );
const setActivePersonaStmt = db.prepare(` const setActivePersonaStmt = db.prepare(`
@@ -308,6 +322,12 @@ const setActivePersonaStmt = db.prepare(`
ON CONFLICT(username) DO UPDATE SET active_persona_id = excluded.active_persona_id ON CONFLICT(username) DO UPDATE SET active_persona_id = excluded.active_persona_id
`); `);
const setActiveUserPersonaStmt = db.prepare(`
INSERT INTO user_settings (username, active_user_persona_id)
VALUES (?, ?)
ON CONFLICT(username) DO UPDATE SET active_user_persona_id = excluded.active_user_persona_id
`);
const getActivePersonaStmt = db.prepare(` const getActivePersonaStmt = db.prepare(`
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details
FROM user_settings us FROM user_settings us
@@ -315,6 +335,13 @@ const getActivePersonaStmt = db.prepare(`
WHERE us.username = ? WHERE us.username = ?
`); `);
const getActiveUserPersonaStmt = 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_user_persona_id
WHERE us.username = ?
`);
const migrationNeeded = db.prepare( const migrationNeeded = db.prepare(
"SELECT COUNT(*) as count FROM chat_messages" "SELECT COUNT(*) as count FROM chat_messages"
).get(); ).get();
@@ -339,6 +366,16 @@ const hasCustomFields = personaColumns.some(column => column.name === "custom_fi
if (!hasCustomFields) { if (!hasCustomFields) {
db.prepare("ALTER TABLE personas ADD COLUMN custom_fields TEXT").run(); db.prepare("ALTER TABLE personas ADD COLUMN custom_fields TEXT").run();
} }
const hasPersonaType = personaColumns.some(column => column.name === "persona_type");
if (!hasPersonaType) {
db.prepare("ALTER TABLE personas ADD COLUMN persona_type TEXT DEFAULT 'assistant'").run();
}
const userSettingsColumns = db.prepare("PRAGMA table_info(user_settings)").all();
const hasActiveUserPersona = userSettingsColumns.some(column => column.name === "active_user_persona_id");
if (!hasActiveUserPersona) {
db.prepare("ALTER TABLE user_settings ADD COLUMN active_user_persona_id INTEGER").run();
}
const FileStore = FileStoreFactory(session); const FileStore = FileStoreFactory(session);
app.use(bodyParser.json()); app.use(bodyParser.json());
@@ -399,6 +436,18 @@ const buildPersonaPrompt = (persona) => {
return lines.join("\n"); return lines.join("\n");
}; };
const buildUserPersonaPrompt = (persona) => {
const lines = [
"The user is roleplaying as the following persona. Keep this context in mind when responding."
];
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) => { const requireLogin = (req, res, next) => {
if (!req.session.user) return res.status(403).json({error: "Not logged in"}); if (!req.session.user) return res.status(403).json({error: "Not logged in"});
next(); next();
@@ -484,9 +533,15 @@ app.post("/chats/:id/clear", requireLogin, (req, res) => {
app.get("/personas", requireLogin, (req, res) => { app.get("/personas", requireLogin, (req, res) => {
const user = req.session.user; const user = req.session.user;
const personas = listPersonasStmt.all(user); const assistantPersonas = listPersonasByTypeStmt.all(user, "assistant");
const activePersonaId = getActivePersonaIdStmt.get(user)?.active_persona_id ?? null; const userPersonas = listPersonasByTypeStmt.all(user, "user");
res.json({personas, activePersonaId}); const activePersonas = getActivePersonaIdStmt.get(user) || {};
res.json({
assistantPersonas,
userPersonas,
activePersonaId: activePersonas.active_persona_id ?? null,
activeUserPersonaId: activePersonas.active_user_persona_id ?? null
});
}); });
app.post("/personas", requireLogin, (req, res) => { app.post("/personas", requireLogin, (req, res) => {
@@ -495,6 +550,10 @@ app.post("/personas", requireLogin, (req, res) => {
if (!name) { if (!name) {
return res.status(400).json({error: "Name is required"}); return res.status(400).json({error: "Name is required"});
} }
const personaType = (req.body?.personaType || "assistant").trim();
if (!["assistant", "user"].includes(personaType)) {
return res.status(400).json({error: "Invalid persona type"});
}
const pronouns = normalizePersonaField(req.body?.pronouns); const pronouns = normalizePersonaField(req.body?.pronouns);
const appearance = normalizePersonaField(req.body?.appearance); const appearance = normalizePersonaField(req.body?.appearance);
const background = normalizePersonaField(req.body?.background); const background = normalizePersonaField(req.body?.background);
@@ -505,7 +564,8 @@ app.post("/personas", requireLogin, (req, res) => {
pronouns, pronouns,
appearance, appearance,
background, background,
details details,
personaType
).lastInsertRowid; ).lastInsertRowid;
const persona = getPersonaStmt.get(personaId, user); const persona = getPersonaStmt.get(personaId, user);
res.json({persona}); res.json({persona});
@@ -541,14 +601,17 @@ app.put("/personas/:id", requireLogin, (req, res) => {
app.delete("/personas/:id", requireLogin, (req, res) => { app.delete("/personas/:id", requireLogin, (req, res) => {
const user = req.session.user; const user = req.session.user;
const personaId = Number(req.params.id); const personaId = Number(req.params.id);
const activePersonaId = getActivePersonaIdStmt.get(user)?.active_persona_id ?? null; const activePersonas = getActivePersonaIdStmt.get(user) || {};
const result = deletePersonaStmt.run(personaId, user); const result = deletePersonaStmt.run(personaId, user);
if (result.changes === 0) { if (result.changes === 0) {
return res.status(404).json({error: "Persona not found"}); return res.status(404).json({error: "Persona not found"});
} }
if (activePersonaId === personaId) { if (activePersonas.active_persona_id === personaId) {
setActivePersonaStmt.run(user, null); setActivePersonaStmt.run(user, null);
} }
if (activePersonas.active_user_persona_id === personaId) {
setActiveUserPersonaStmt.run(user, null);
}
res.json({message: "Persona deleted"}); res.json({message: "Persona deleted"});
}); });
@@ -556,19 +619,36 @@ app.post("/personas/:id/equip", requireLogin, (req, res) => {
const user = req.session.user; const user = req.session.user;
const personaId = Number(req.params.id); const personaId = Number(req.params.id);
const persona = getPersonaStmt.get(personaId, user); const persona = getPersonaStmt.get(personaId, user);
if (!persona) { if (!persona || persona.persona_type !== "assistant") {
return res.status(404).json({error: "Persona not found"}); return res.status(404).json({error: "Persona not found"});
} }
setActivePersonaStmt.run(user, personaId); setActivePersonaStmt.run(user, personaId);
res.json({activePersonaId: personaId}); res.json({activePersonaId: personaId});
}); });
app.post("/personas/:id/equip-user", requireLogin, (req, res) => {
const user = req.session.user;
const personaId = Number(req.params.id);
const persona = getPersonaStmt.get(personaId, user);
if (!persona || persona.persona_type !== "user") {
return res.status(404).json({error: "Persona not found"});
}
setActiveUserPersonaStmt.run(user, personaId);
res.json({activeUserPersonaId: personaId});
});
app.post("/personas/clear", requireLogin, (req, res) => { app.post("/personas/clear", requireLogin, (req, res) => {
const user = req.session.user; const user = req.session.user;
setActivePersonaStmt.run(user, null); setActivePersonaStmt.run(user, null);
res.json({activePersonaId: null}); res.json({activePersonaId: null});
}); });
app.post("/personas/user/clear", requireLogin, (req, res) => {
const user = req.session.user;
setActiveUserPersonaStmt.run(user, null);
res.json({activeUserPersonaId: null});
});
app.post("/chat", requireLogin, async (req, res) => { app.post("/chat", requireLogin, async (req, res) => {
const user = req.session.user; const user = req.session.user;
const {message, model, chatId} = req.body; const {message, model, chatId} = req.body;
@@ -592,6 +672,7 @@ app.post("/chat", requireLogin, async (req, res) => {
try { try {
const activePersona = getActivePersonaStmt.get(user); const activePersona = getActivePersonaStmt.get(user);
const activeUserPersona = getActiveUserPersonaStmt.get(user);
const history = getRecentChatMessagesStmt.all(targetChatId); const history = getRecentChatMessagesStmt.all(targetChatId);
const conversation = [...history].reverse(); const conversation = [...history].reverse();
conversation.push({role: "user", content: message}); conversation.push({role: "user", content: message});
@@ -604,6 +685,12 @@ app.post("/chat", requireLogin, async (req, res) => {
content: buildPersonaPrompt(activePersona) content: buildPersonaPrompt(activePersona)
}); });
} }
if (activeUserPersona) {
messagesPayload.push({
role: "system",
content: buildUserPersonaPrompt(activeUserPersona)
});
}
messagesPayload.push( messagesPayload.push(
...conversation.map(m => ({ ...conversation.map(m => ({
role: m.role === "bot" ? "assistant" : "user", role: m.role === "bot" ? "assistant" : "user",