Move persona editor into modal

This commit is contained in:
2026-01-07 21:06:04 +01:00
parent c9fbc1a55a
commit 2cd6c9c4d7
4 changed files with 740 additions and 12 deletions
+57 -1
View File
@@ -75,6 +75,20 @@
<button id="newChat" class="secondary-action">New</button>
</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>
<section class="chat-main">
<div class="chat-header">
@@ -102,8 +116,50 @@
</div>
</div>
</div>
<div id="personaModal" class="modal-overlay hidden">
<div class="modal-card">
<div class="modal-header">
<h3 id="personaFormTitle">Create persona</h3>
<button id="personaClose" type="button" class="icon-button">Close</button>
</div>
<form id="personaForm" class="persona-form">
<label class="field">
<span>Name</span>
<input id="personaName" placeholder="Persona name" required/>
</label>
<label class="field">
<span>Pronouns</span>
<input id="personaPronouns" placeholder="e.g. she/her, they/them"/>
</label>
<label class="field">
<span>Appearance</span>
<textarea id="personaAppearance" rows="2" placeholder="Describe their look"></textarea>
</label>
<label class="field">
<span>Background</span>
<textarea id="personaBackground" rows="2" placeholder="Backstory or origins"></textarea>
</label>
<label class="field">
<span>Details</span>
<textarea id="personaDetails" rows="3"
placeholder="Personality traits, voice, or quirks"></textarea>
</label>
<div class="custom-fields">
<div class="custom-fields-header">
<span>Custom traits</span>
<button id="addCustomField" type="button" class="secondary-action">Add</button>
</div>
<div id="customFieldsList" class="custom-fields-list"></div>
</div>
<div class="persona-actions">
<button id="personaSave" type="submit" class="primary-action">Save persona</button>
<button id="personaCancel" type="button" class="secondary-action">Cancel</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js" defer></script>
<script src="script.js" defer></script>
</body>
</html>
</html>
+242 -5
View File
@@ -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();
+167
View File
@@ -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;
+274 -6
View File
@@ -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",