Add user personas and move persona controls to popover
This commit is contained in:
+50
-19
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user