Revamp overall theme

This commit is contained in:
2026-04-09 12:05:28 +02:00
parent 94d6a8dc2e
commit 602e29e54b
8 changed files with 1435 additions and 198 deletions
+49 -23
View File
@@ -80,9 +80,11 @@
<div class="sidebar-header"> <div class="sidebar-header">
<div> <div>
<p class="eyebrow">Chats</p> <p class="eyebrow">Chats</p>
<h2>Saved threads</h2> <h2>Stories & chats</h2>
</div>
<div class="sidebar-header-actions">
<button id="newChat" class="secondary-action" type="button">New chat</button>
</div> </div>
<button id="newChat" class="secondary-action" type="button">New</button>
</div> </div>
<label class="field compact-field"> <label class="field compact-field">
<span>Search</span> <span>Search</span>
@@ -107,6 +109,11 @@
<div class="chat-title"> <div class="chat-title">
<p class="eyebrow">Conversation</p> <p class="eyebrow">Conversation</p>
<h2 id="activeChatTitle">New chat</h2> <h2 id="activeChatTitle">New chat</h2>
<div id="chatParticipants" class="chat-participants">
<span id="chatModePill" class="context-pill chat-mode-pill">Assistant mode</span>
<span id="chatCharacterPill" class="context-pill hidden">Character: none</span>
<span id="chatUserPersonaPill" class="context-pill hidden">User persona: none</span>
</div>
</div> </div>
<div class="chat-toolbar"> <div class="chat-toolbar">
<button id="renameChat" class="icon-button" type="button">Rename</button> <button id="renameChat" class="icon-button" type="button">Rename</button>
@@ -144,24 +151,9 @@
</div> </div>
<div class="persona-menu"> <div class="persona-menu">
<button id="personaMenuButton" class="secondary-action" type="button" aria-expanded="false" aria-controls="personaPopover"> <button id="personaMenuButton" class="secondary-action" type="button" aria-expanded="false" aria-controls="personaPopover">
Personas You
</button> </button>
<div id="personaPopover" class="persona-popover hidden" role="dialog"> <div id="personaPopover" class="persona-popover hidden" role="dialog">
<div class="persona-section">
<div class="persona-section-header">
<div>
<p class="eyebrow">Assistant</p>
<h2>AI persona</h2>
<p class="persona-guidance">Changes the assistant's tone and character.</p>
</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 AI persona equipped.</p>
<div id="personaList" class="persona-list"></div>
</div>
<div class="persona-section"> <div class="persona-section">
<div class="persona-section-header"> <div class="persona-section-header">
<div> <div>
@@ -170,11 +162,11 @@
<p class="persona-guidance">Adds context for how the assistant should read you.</p> <p class="persona-guidance">Adds context for how the assistant should read you.</p>
</div> </div>
<div class="persona-section-actions"> <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> <button id="clearUserPersona" class="secondary-action" type="button">Clear</button>
<a href="/settings" class="secondary-action">Manage</a>
</div> </div>
</div> </div>
<p id="activeUserPersonaStatus" class="status persona-status">No user persona set.</p> <p id="activeUserPersonaStatus" class="status persona-status">No default user persona set.</p>
<div id="userPersonaList" class="persona-list"></div> <div id="userPersonaList" class="persona-list"></div>
</div> </div>
</div> </div>
@@ -205,18 +197,18 @@
<p class="eyebrow">Persona editor</p> <p class="eyebrow">Persona editor</p>
<h3 id="personaFormTitle">Create persona</h3> <h3 id="personaFormTitle">Create persona</h3>
</div> </div>
<button id="personaClose" type="button" class="icon-button">Cancel</button> <button id="personaClose" type="button" class="icon-button">Close</button>
</div> </div>
<form id="personaForm" class="persona-form"> <form id="personaForm" class="persona-form">
<div class="persona-form-intro"> <div class="persona-form-intro">
<p class="persona-helper">AI personas shape the assistant. User personas add context for how replies should read you.</p> <p class="persona-helper">AI Characters shape who you talk to. User personas add context for who you are in the scene.</p>
</div> </div>
<p id="personaFormNotice" class="status hidden"></p> <p id="personaFormNotice" class="status hidden"></p>
<div class="persona-form-grid"> <div class="persona-form-grid">
<label class="field field-wide"> <label class="field field-wide">
<span>Persona for</span> <span>Persona for</span>
<select id="personaType"> <select id="personaType">
<option value="assistant">AI persona</option> <option value="assistant">AI Character</option>
<option value="user">Your persona</option> <option value="user">Your persona</option>
</select> </select>
</label> </label>
@@ -258,6 +250,40 @@
</div> </div>
</div> </div>
<div id="roleplayStarterModal" class="modal-overlay hidden">
<div class="modal-card roleplay-starter-card">
<div class="modal-header">
<div class="persona-modal-heading">
<p class="eyebrow">Start roleplay</p>
<h3>Choose your scene</h3>
</div>
<button id="roleplayStarterClose" type="button" class="icon-button">Close</button>
</div>
<div class="roleplay-starter-intro">
<p class="persona-helper">Pick a character, optionally choose who you are in the scene, and the character will open the conversation with the first message.</p>
</div>
<p id="roleplayStarterNotice" class="status hidden"></p>
<div class="roleplay-starter-actions">
<button id="roleplayNewPersona" class="secondary-action" type="button">New persona</button>
<a href="/settings" class="secondary-action">Settings</a>
</div>
<div class="persona-form-grid">
<label class="field field-wide">
<span>Character</span>
<select id="roleplayCharacterSelect"></select>
</label>
<label class="field field-wide">
<span>User persona</span>
<select id="roleplayUserPersonaSelect"></select>
</label>
</div>
<div class="popup-actions">
<button id="roleplayStarterCancel" type="button" class="secondary-action">Cancel</button>
<button id="roleplayStarterConfirm" type="button">Start roleplay</button>
</div>
</div>
</div>
<div id="popupModal" class="modal-overlay hidden"> <div id="popupModal" class="modal-overlay hidden">
<div class="modal-card popup-card" role="dialog" aria-modal="true" aria-labelledby="popupTitle" aria-describedby="popupDescription"> <div class="modal-card popup-card" role="dialog" aria-modal="true" aria-labelledby="popupTitle" aria-describedby="popupDescription">
<div class="modal-header"> <div class="modal-header">
+110 -11
View File
@@ -15,7 +15,7 @@
<div> <div>
<p class="eyebrow">Shared personas</p> <p class="eyebrow">Shared personas</p>
<h1><span data-theme-name>FakeGPT</span> Market</h1> <h1><span data-theme-name>FakeGPT</span> Market</h1>
<p class="subtitle">A plain list of personas other people decided to share.</p> <p class="subtitle">Browse shared AI Characters and User Personas, or manage your own AI Characters.</p>
</div> </div>
<div class="market-header-actions"> <div class="market-header-actions">
<button id="refreshMarket" class="secondary-action" type="button">Refresh</button> <button id="refreshMarket" class="secondary-action" type="button">Refresh</button>
@@ -31,16 +31,55 @@
<p id="marketStatus" class="status persona-status">Loading market personas...</p> <p id="marketStatus" class="status persona-status">Loading market personas...</p>
<div class="persona-market-columns market-page-columns"> <section class="settings-layout market-settings-layout">
<div class="persona-market-column"> <aside class="settings-nav" aria-label="Market sections">
<h3>AI personas</h3> <button class="settings-nav-item active" type="button" data-market-view="ai-characters">AI Characters</button>
<div id="marketPersonaList" class="persona-list"></div> <button class="settings-nav-item" type="button" data-market-view="user-personas">User Personas</button>
<button class="settings-nav-item" type="button" data-market-view="my-personas">My Personas</button>
</aside>
<div class="settings-content">
<section class="settings-view active" data-market-panel="ai-characters">
<div class="settings-panel">
<div class="settings-section-header">
<div>
<p class="eyebrow">Discover</p>
<h2>AI Characters</h2>
<p class="subtitle">Find shared characters to start roleplay chats with.</p>
</div>
</div>
<div id="marketPersonaList" class="persona-list"></div>
</div>
</section>
<section class="settings-view" data-market-panel="user-personas">
<div class="settings-panel">
<div class="settings-section-header">
<div>
<p class="eyebrow">Discover</p>
<h2>User Personas</h2>
<p class="subtitle">Browse shared user personas you can collect for roleplay context.</p>
</div>
</div>
<div id="marketUserPersonaList" class="persona-list"></div>
</div>
</section>
<section class="settings-view" data-market-panel="my-personas">
<div class="settings-panel">
<div class="settings-section-header">
<div>
<p class="eyebrow">Manage</p>
<h2>My Personas</h2>
<p class="subtitle">Create and manage your own AI Characters.</p>
</div>
<button id="marketCreatePersona" type="button" class="secondary-action">Create persona</button>
</div>
<div id="marketOwnedPersonaList" class="persona-list"></div>
</div>
</section>
</div> </div>
<div class="persona-market-column"> </section>
<h3>User personas</h3>
<div id="marketUserPersonaList" class="persona-list"></div>
</div>
</div>
</div> </div>
<div id="marketPreviewModal" class="modal-overlay hidden"> <div id="marketPreviewModal" class="modal-overlay hidden">
@@ -71,14 +110,74 @@
<h4>Details</h4> <h4>Details</h4>
<p id="marketPreviewDetails"></p> <p id="marketPreviewDetails"></p>
</div> </div>
<div>
<h4>Example dialogues</h4>
<p id="marketPreviewExampleDialogues"></p>
</div>
</div> </div>
</div> </div>
<div class="market-preview-actions"> <div class="market-preview-actions">
<label id="marketPreviewUserPersonaField" class="field market-preview-user-field hidden">
<span>Talk as</span>
<select id="marketPreviewUserPersonaSelect"></select>
</label>
<button id="marketPreviewConfirm" type="button" class="primary-action">Take persona</button> <button id="marketPreviewConfirm" type="button" class="primary-action">Take persona</button>
<button id="marketPreviewCancel" type="button" class="secondary-action">Cancel</button>
</div> </div>
</div> </div>
</div> </div>
<div id="marketPersonaModal" class="modal-overlay hidden">
<div class="modal-card persona-modal-card">
<div class="modal-header">
<div class="persona-modal-heading">
<p class="eyebrow">AI Character editor</p>
<h3 id="marketPersonaFormTitle">Create AI Character</h3>
</div>
<button id="marketPersonaClose" type="button" class="icon-button">Close</button>
</div>
<form id="marketPersonaForm" class="persona-form">
<div class="persona-form-intro">
<p class="persona-helper">Create or update your AI Characters for roleplay chats and marketplace publishing.</p>
</div>
<p id="marketPersonaFormNotice" class="status hidden"></p>
<div class="persona-form-grid">
<label class="field">
<span>Character name</span>
<input id="marketPersonaName" placeholder="Character name" required/>
</label>
<label class="field">
<span>Pronouns</span>
<input id="marketPersonaPronouns" placeholder="e.g. she/her, they/them"/>
</label>
<label class="field field-wide">
<span>Appearance</span>
<textarea id="marketPersonaAppearance" rows="3" placeholder="Describe their look"></textarea>
</label>
<label class="field field-wide">
<span>Background</span>
<textarea id="marketPersonaBackground" rows="3" placeholder="Backstory, history, role, or origins"></textarea>
</label>
<label class="field field-wide">
<span>Details</span>
<textarea id="marketPersonaDetails" rows="4" placeholder="Personality traits, voice, goals, quirks, boundaries, style"></textarea>
</label>
<label class="field field-wide">
<span>Example dialogues</span>
<div class="dialogue-format-card" aria-hidden="true">
<p class="dialogue-format-label">Format</p>
<pre class="dialogue-format-example">{{character}}: Hello, my name is marc
{{user}}: Hi, Marc!
{{character}}: Nice to meet you</pre>
</div>
<textarea id="marketPersonaExampleDialogues" rows="5" placeholder="Write example exchanges that define the character's tone, phrasing, pacing, and personality."></textarea>
</label>
</div>
<div class="persona-actions">
<button id="marketPersonaSave" type="submit" class="primary-action">Save AI Character</button>
</div>
</form>
</div>
</div>
<script src="market.js" defer></script> <script src="market.js" defer></script>
</body> </body>
</html> </html>
+334 -37
View File
@@ -1,11 +1,37 @@
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const marketPersonaList = $("marketPersonaList"), marketUserPersonaList = $("marketUserPersonaList"); const marketPersonaList = $("marketPersonaList");
const refreshMarketBtn = $("refreshMarket"), marketStatus = $("marketStatus"), marketSearchInput = $("marketSearch"); const marketUserPersonaList = $("marketUserPersonaList");
const marketPreviewModal = $("marketPreviewModal"), marketPreviewName = $("marketPreviewName"), marketPreviewMeta = $("marketPreviewMeta"); const marketOwnedPersonaList = $("marketOwnedPersonaList");
const marketPreviewPronouns = $("marketPreviewPronouns"), marketPreviewAppearance = $("marketPreviewAppearance"), marketPreviewBackground = $("marketPreviewBackground"), marketPreviewDetails = $("marketPreviewDetails"); const refreshMarketBtn = $("refreshMarket");
const marketPreviewConfirm = $("marketPreviewConfirm"), marketPreviewCancel = $("marketPreviewCancel"), marketPreviewClose = $("marketPreviewClose"); const marketStatus = $("marketStatus");
const marketSearchInput = $("marketSearch");
const marketViewButtons = document.querySelectorAll("[data-market-view]");
const marketPanels = document.querySelectorAll("[data-market-panel]");
const marketCreatePersonaBtn = $("marketCreatePersona");
const marketPreviewModal = $("marketPreviewModal");
const marketPreviewName = $("marketPreviewName");
const marketPreviewMeta = $("marketPreviewMeta");
const marketPreviewPronouns = $("marketPreviewPronouns");
const marketPreviewAppearance = $("marketPreviewAppearance");
const marketPreviewBackground = $("marketPreviewBackground");
const marketPreviewDetails = $("marketPreviewDetails");
const marketPreviewExampleDialogues = $("marketPreviewExampleDialogues");
const marketPreviewUserPersonaField = $("marketPreviewUserPersonaField");
const marketPreviewUserPersonaSelect = $("marketPreviewUserPersonaSelect");
const marketPreviewConfirm = $("marketPreviewConfirm");
const marketPreviewClose = $("marketPreviewClose");
const marketPersonaModal = $("marketPersonaModal");
const marketPersonaClose = $("marketPersonaClose");
const marketPersonaForm = $("marketPersonaForm");
const marketPersonaFormTitle = $("marketPersonaFormTitle");
const marketPersonaFormNotice = $("marketPersonaFormNotice");
const marketPersonaName = $("marketPersonaName");
const marketPersonaPronouns = $("marketPersonaPronouns");
const marketPersonaAppearance = $("marketPersonaAppearance");
const marketPersonaBackground = $("marketPersonaBackground");
const marketPersonaDetails = $("marketPersonaDetails");
const marketPersonaExampleDialogues = $("marketPersonaExampleDialogues");
const themeNameTargets = document.querySelectorAll("[data-theme-name]"); const themeNameTargets = document.querySelectorAll("[data-theme-name]");
let allMarketPersonas = [], pendingMarketPersona = null;
const themes = { const themes = {
"fakegpt": {name: "FakeGPT"}, "fakegpt": {name: "FakeGPT"},
@@ -16,9 +42,20 @@ const themes = {
"confusity": {name: "Confusity"} "confusity": {name: "Confusity"}
}; };
let allMarketPersonas = [];
let assistantPersonas = [];
let userPersonas = [];
let publishedPersonaIds = new Set();
let pendingMarketPersona = null;
let editingPersonaId = null;
async function request(url, data, method = "POST") { async function request(url, data, method = "POST") {
try { try {
const res = await fetch(url, {method, headers: {"Content-Type": "application/json"}, body: data ? JSON.stringify(data) : undefined}); const res = await fetch(url, {
method,
headers: {"Content-Type": "application/json"},
body: data ? JSON.stringify(data) : undefined
});
return await res.json(); return await res.json();
} catch { } catch {
return {error: "Network error - please try again."}; return {error: "Network error - please try again."};
@@ -27,12 +64,16 @@ async function request(url, data, method = "POST") {
const get = (url) => request(url, null, "GET"); const get = (url) => request(url, null, "GET");
const post = (url, data) => request(url, data, "POST"); const post = (url, data) => request(url, data, "POST");
const put = (url, data) => request(url, data, "PUT");
const del = (url) => request(url, null, "DELETE");
function applyTheme(themeKey) { function applyTheme(themeKey) {
const nextTheme = themes[themeKey] ? themeKey : "fakegpt"; const nextTheme = themes[themeKey] ? themeKey : "fakegpt";
document.body.dataset.theme = nextTheme; document.body.dataset.theme = nextTheme;
document.title = `${themes[nextTheme].name} Market`; document.title = `${themes[nextTheme].name} Market`;
themeNameTargets.forEach((target) => { target.textContent = themes[nextTheme].name; }); themeNameTargets.forEach((target) => {
target.textContent = themes[nextTheme].name;
});
} }
function setMarketStatus(message, state = "") { function setMarketStatus(message, state = "") {
@@ -40,9 +81,28 @@ function setMarketStatus(message, state = "") {
marketStatus.className = state ? `status persona-status ${state}` : "status persona-status"; marketStatus.className = state ? `status persona-status ${state}` : "status persona-status";
} }
function setPersonaFormNotice(message = "", state = "") {
if (!message) {
marketPersonaFormNotice.textContent = "";
marketPersonaFormNotice.className = "status hidden";
return;
}
marketPersonaFormNotice.textContent = message;
marketPersonaFormNotice.className = state ? `status ${state}` : "status";
}
function setMarketView(view) {
marketViewButtons.forEach((item) => item.classList.toggle("active", item.dataset.marketView === view));
marketPanels.forEach((panel) => panel.classList.toggle("active", panel.dataset.marketPanel === view));
const params = new URLSearchParams(window.location.search);
params.set("view", view);
history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`);
}
function buildPersonaMeta(persona) { function buildPersonaMeta(persona) {
const creator = persona.creator_username || "You";
const pronouns = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a"; const pronouns = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a";
return `${persona.creator_username} | ${pronouns}`; return `${creator} | ${pronouns}`;
} }
function formatPersonaField(value) { function formatPersonaField(value) {
@@ -52,6 +112,74 @@ function formatPersonaField(value) {
function closeMarketPreview() { function closeMarketPreview() {
marketPreviewModal.classList.add("hidden"); marketPreviewModal.classList.add("hidden");
pendingMarketPersona = null; pendingMarketPersona = null;
marketPreviewUserPersonaSelect.innerHTML = "";
marketPreviewConfirm.classList.add("hidden");
}
function populateUserPersonaChoices() {
marketPreviewUserPersonaSelect.innerHTML = "";
const promptOption = document.createElement("option");
promptOption.value = "";
promptOption.textContent = "Choose who you are";
promptOption.disabled = true;
promptOption.selected = true;
marketPreviewUserPersonaSelect.appendChild(promptOption);
const selfOption = document.createElement("option");
selfOption.value = "self";
selfOption.textContent = "Yourself (no persona)";
marketPreviewUserPersonaSelect.appendChild(selfOption);
userPersonas.forEach((persona) => {
const option = document.createElement("option");
option.value = String(persona.id);
option.textContent = persona.name;
marketPreviewUserPersonaSelect.appendChild(option);
});
const createOption = document.createElement("option");
createOption.value = "create_new";
createOption.textContent = "Create new persona";
marketPreviewUserPersonaSelect.appendChild(createOption);
}
function openMarketPreview(persona) {
pendingMarketPersona = persona;
marketPreviewName.textContent = persona.name;
marketPreviewMeta.textContent = buildPersonaMeta(persona);
marketPreviewPronouns.textContent = formatPersonaField(persona.pronouns);
marketPreviewAppearance.textContent = formatPersonaField(persona.appearance);
marketPreviewBackground.textContent = formatPersonaField(persona.background);
marketPreviewDetails.textContent = formatPersonaField(persona.details);
marketPreviewExampleDialogues.textContent = formatPersonaField(persona.example_dialogues);
const isAssistant = persona.persona_type === "assistant";
marketPreviewUserPersonaField.classList.toggle("hidden", !isAssistant);
marketPreviewConfirm.textContent = isAssistant ? "Start chat" : "Collect as user persona";
marketPreviewConfirm.classList.toggle("hidden", isAssistant);
if (isAssistant) populateUserPersonaChoices();
marketPreviewModal.classList.remove("hidden");
}
function openPersonaForm(persona = null) {
editingPersonaId = persona ? persona.id : null;
marketPersonaFormTitle.textContent = persona ? "Edit AI Character" : "Create AI Character";
marketPersonaName.value = persona?.name || "";
marketPersonaPronouns.value = persona?.pronouns || "";
marketPersonaAppearance.value = persona?.appearance || "";
marketPersonaBackground.value = persona?.background || "";
marketPersonaDetails.value = persona?.details || "";
marketPersonaExampleDialogues.value = persona?.example_dialogues || "";
setPersonaFormNotice("");
marketPersonaModal.classList.remove("hidden");
marketPersonaName.focus();
}
function closePersonaForm() {
marketPersonaModal.classList.add("hidden");
editingPersonaId = null;
setPersonaFormNotice("");
} }
function renderMarketList(items, listElement, type) { function renderMarketList(items, listElement, type) {
@@ -59,7 +187,7 @@ function renderMarketList(items, listElement, type) {
if (!items.length) { if (!items.length) {
const empty = document.createElement("p"); const empty = document.createElement("p");
empty.className = "status persona-status"; empty.className = "status persona-status";
empty.textContent = type === "assistant" ? "No AI personas found." : "No user personas found."; empty.textContent = type === "assistant" ? "No AI Characters found." : "No User Personas found.";
listElement.appendChild(empty); listElement.appendChild(empty);
return; return;
} }
@@ -77,9 +205,10 @@ function renderMarketList(items, listElement, type) {
titleRow.className = "persona-title-row"; titleRow.className = "persona-title-row";
actions.className = "persona-item-actions"; actions.className = "persona-item-actions";
tag.className = `persona-role-tag ${type === "assistant" ? "ai" : "user"}`; tag.className = `persona-role-tag ${type === "assistant" ? "ai" : "user"}`;
tag.textContent = type === "assistant" ? "AI" : "You"; tag.textContent = type === "assistant" ? "AI Character" : "User";
title.textContent = persona.name; title.textContent = persona.name;
meta.textContent = buildPersonaMeta(persona); meta.textContent = buildPersonaMeta(persona);
detailsBtn.type = "button"; detailsBtn.type = "button";
detailsBtn.textContent = "View"; detailsBtn.textContent = "View";
detailsBtn.addEventListener("click", () => openMarketPreview(persona)); detailsBtn.addEventListener("click", () => openMarketPreview(persona));
@@ -91,11 +220,63 @@ function renderMarketList(items, listElement, type) {
}); });
} }
function renderOwnedPersonaList() {
marketOwnedPersonaList.innerHTML = "";
if (!assistantPersonas.length) {
const empty = document.createElement("p");
empty.className = "status persona-status";
empty.textContent = "No AI Characters yet. Create one to get started.";
marketOwnedPersonaList.appendChild(empty);
return;
}
assistantPersonas.forEach((persona) => {
const item = document.createElement("div");
const titleRow = document.createElement("div");
const title = document.createElement("h4");
const tag = document.createElement("span");
const meta = document.createElement("p");
const actions = document.createElement("div");
item.className = "persona-item";
titleRow.className = "persona-title-row";
actions.className = "persona-item-actions";
tag.className = "persona-role-tag ai";
tag.textContent = "AI Character";
title.textContent = persona.name;
meta.textContent = formatPersonaField(persona.pronouns) === "Not provided." ? "Pronouns: n/a" : `Pronouns: ${persona.pronouns}`;
[
["Edit", () => openPersonaForm(persona)],
[publishedPersonaIds.has(persona.id) ? "Update listing" : "Publish", () => publishPersona(persona.id)],
["Delete", () => deletePersona(persona.id)]
].forEach(([label, handler]) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.addEventListener("click", handler);
actions.appendChild(button);
});
if (publishedPersonaIds.has(persona.id)) {
const unpublish = document.createElement("button");
unpublish.type = "button";
unpublish.textContent = "Unpublish";
unpublish.addEventListener("click", () => unpublishPersona(persona.id));
actions.appendChild(unpublish);
}
titleRow.append(title, tag);
item.append(titleRow, meta, actions);
marketOwnedPersonaList.appendChild(item);
});
}
function filterMarketPersonas() { function filterMarketPersonas() {
const query = marketSearchInput.value.trim().toLowerCase(); const query = marketSearchInput.value.trim().toLowerCase();
if (!query) return allMarketPersonas; if (!query) return allMarketPersonas;
return allMarketPersonas.filter((persona) => return allMarketPersonas.filter((persona) =>
[persona.name, persona.creator_username, persona.pronouns, persona.appearance, persona.background, persona.details] [persona.name, persona.creator_username, persona.pronouns, persona.appearance, persona.background, persona.details, persona.example_dialogues]
.some((field) => field && field.toLowerCase().includes(query)) .some((field) => field && field.toLowerCase().includes(query))
); );
} }
@@ -104,6 +285,8 @@ function applyMarketFilter() {
const filtered = filterMarketPersonas(); const filtered = filterMarketPersonas();
renderMarketList(filtered.filter((persona) => persona.persona_type === "assistant"), marketPersonaList, "assistant"); renderMarketList(filtered.filter((persona) => persona.persona_type === "assistant"), marketPersonaList, "assistant");
renderMarketList(filtered.filter((persona) => persona.persona_type === "user"), marketUserPersonaList, "user"); renderMarketList(filtered.filter((persona) => persona.persona_type === "user"), marketUserPersonaList, "user");
renderOwnedPersonaList();
if (marketSearchInput.value.trim() && !filtered.length) { if (marketSearchInput.value.trim() && !filtered.length) {
setMarketStatus("No personas match your search.", "error"); setMarketStatus("No personas match your search.", "error");
return; return;
@@ -111,48 +294,162 @@ function applyMarketFilter() {
setMarketStatus(`${filtered.length} persona${filtered.length === 1 ? "" : "s"} shown.`); setMarketStatus(`${filtered.length} persona${filtered.length === 1 ? "" : "s"} shown.`);
} }
function openMarketPreview(persona) { async function loadOwnedPersonas() {
pendingMarketPersona = persona; const res = await get("/personas");
marketPreviewName.textContent = persona.name; if (res.error) return false;
marketPreviewMeta.textContent = buildPersonaMeta(persona); assistantPersonas = res.assistantPersonas || [];
marketPreviewPronouns.textContent = formatPersonaField(persona.pronouns); userPersonas = res.userPersonas || [];
marketPreviewAppearance.textContent = formatPersonaField(persona.appearance); publishedPersonaIds = new Set(res.publishedPersonaIds || []);
marketPreviewBackground.textContent = formatPersonaField(persona.background); return true;
marketPreviewDetails.textContent = formatPersonaField(persona.details);
marketPreviewConfirm.textContent = persona.persona_type === "assistant" ? "Collect as AI persona" : "Collect as user persona";
marketPreviewModal.classList.remove("hidden");
} }
async function loadMarketPersonas() { async function loadMarketPersonas() {
setMarketStatus("Loading market personas..."); setMarketStatus("Loading personas...");
const res = await get("/personas/market"); const [marketRes, ownedOk] = await Promise.all([get("/personas/market"), loadOwnedPersonas()]);
if (res.error) return setMarketStatus(res.error, "error"); if (marketRes.error) {
allMarketPersonas = res.personas || []; setMarketStatus(marketRes.error, "error");
return;
}
if (!ownedOk) {
setMarketStatus("Unable to load your personas.", "error");
return;
}
allMarketPersonas = marketRes.personas || [];
applyMarketFilter(); applyMarketFilter();
} }
async function collectMarketPersona(marketId, personaType) { async function collectMarketPersona(marketId) {
setMarketStatus(personaType === "assistant" ? "Collecting AI persona..." : "Collecting user persona..."); setMarketStatus("Collecting User Persona...");
const res = await post(`/personas/market/${marketId}/collect`, {equip: true}); const res = await post(`/personas/market/${marketId}/collect`, {equip: true});
if (res.error) return setMarketStatus(res.error, "error"); if (res.error) {
setMarketStatus(res.error, "error");
return;
}
await loadMarketPersonas(); await loadMarketPersonas();
setMarketStatus(personaType === "assistant" ? "AI persona collected." : "User persona collected.", "success"); setMarketStatus("User Persona collected.", "success");
} }
refreshMarketBtn.addEventListener("click", () => { void loadMarketPersonas(); }); async function startMarketPersonaChat(marketId, userPersonaSelection) {
if (userPersonaSelection === "create_new") {
window.location.href = "/settings?view=my-personas&create=user";
return;
}
setMarketStatus("Starting AI Character chat...");
const userPersonaId = userPersonaSelection === "self" ? null : Number(userPersonaSelection);
const res = await post(`/personas/market/${marketId}/chat`, {userPersonaId});
if (res.error || !res.chat) {
setMarketStatus(res.error || "Unable to start chat.", "error");
return;
}
window.location.href = `/?chat=${res.chat.id}`;
}
async function savePersona() {
const payload = {
personaType: "assistant",
name: marketPersonaName.value.trim(),
pronouns: marketPersonaPronouns.value.trim(),
appearance: marketPersonaAppearance.value.trim(),
background: marketPersonaBackground.value.trim(),
details: marketPersonaDetails.value.trim(),
exampleDialogues: marketPersonaExampleDialogues.value.trim()
};
if (!payload.name) {
setPersonaFormNotice("Character name is required.", "error");
return;
}
const res = editingPersonaId
? await put(`/personas/${editingPersonaId}`, payload)
: await post("/personas", payload);
if (res.error) {
setPersonaFormNotice(res.error, "error");
return;
}
await loadMarketPersonas();
closePersonaForm();
setMarketView("my-personas");
setMarketStatus(editingPersonaId ? "AI Character updated." : "AI Character created.", "success");
}
async function deletePersona(id) {
const res = await del(`/personas/${id}`);
if (res.error) {
setMarketStatus(res.error, "error");
return;
}
await loadMarketPersonas();
setMarketStatus("AI Character deleted.", "success");
}
async function publishPersona(id) {
const res = await post(`/personas/${id}/publish`, {});
if (res.error) {
setMarketStatus(res.error, "error");
return;
}
await loadMarketPersonas();
setMarketStatus("AI Character published.", "success");
}
async function unpublishPersona(id) {
const res = await post(`/personas/${id}/unpublish`, {});
if (res.error) {
setMarketStatus(res.error, "error");
return;
}
await loadMarketPersonas();
setMarketStatus("AI Character unpublished.", "success");
}
refreshMarketBtn.addEventListener("click", () => {
void loadMarketPersonas();
});
marketSearchInput.addEventListener("input", applyMarketFilter); marketSearchInput.addEventListener("input", applyMarketFilter);
marketPreviewCancel.addEventListener("click", closeMarketPreview); marketViewButtons.forEach((button) => {
button.addEventListener("click", () => setMarketView(button.dataset.marketView));
});
marketCreatePersonaBtn.addEventListener("click", () => openPersonaForm());
marketPreviewClose.addEventListener("click", closeMarketPreview); marketPreviewClose.addEventListener("click", closeMarketPreview);
marketPreviewModal.addEventListener("click", (event) => { if (event.target === marketPreviewModal) closeMarketPreview(); }); marketPreviewModal.addEventListener("click", (event) => {
if (event.target === marketPreviewModal) closeMarketPreview();
});
marketPreviewUserPersonaSelect.addEventListener("change", () => {
if (!pendingMarketPersona || pendingMarketPersona.persona_type !== "assistant") return;
if (marketPreviewUserPersonaSelect.value === "create_new") {
window.location.href = "/settings?view=my-personas&create=user";
return;
}
marketPreviewConfirm.classList.toggle("hidden", !marketPreviewUserPersonaSelect.value);
});
marketPreviewConfirm.addEventListener("click", async () => { marketPreviewConfirm.addEventListener("click", async () => {
if (!pendingMarketPersona) return; if (!pendingMarketPersona) return;
const marketId = pendingMarketPersona.id; const marketId = pendingMarketPersona.id;
const personaType = pendingMarketPersona.persona_type; if (pendingMarketPersona.persona_type === "assistant") {
await startMarketPersonaChat(marketId, marketPreviewUserPersonaSelect.value || "");
closeMarketPreview();
return;
}
closeMarketPreview(); closeMarketPreview();
await collectMarketPersona(marketId, personaType); await collectMarketPersona(marketId);
});
marketPersonaClose.addEventListener("click", closePersonaForm);
marketPersonaModal.addEventListener("click", (event) => {
if (event.target === marketPersonaModal) closePersonaForm();
});
marketPersonaForm.addEventListener("submit", (event) => {
event.preventDefault();
void savePersona();
}); });
window.addEventListener("load", () => { window.addEventListener("load", async () => {
applyTheme(localStorage.getItem("krishd-theme") || "fakegpt"); applyTheme(localStorage.getItem("krishd-theme") || "fakegpt");
void loadMarketPersonas(); const params = new URLSearchParams(window.location.search);
setMarketView(params.get("view") || "ai-characters");
await loadMarketPersonas();
if (params.get("create") === "assistant") openPersonaForm();
}); });
+172 -32
View File
@@ -11,25 +11,30 @@ const loginSubmit = $("loginSubmit"), registerSubmit = $("registerSubmit");
const chatList = $("chatList"), chatSearchInput = $("chatSearch"), newChatBtn = $("newChat"); const chatList = $("chatList"), chatSearchInput = $("chatSearch"), newChatBtn = $("newChat");
const renameChatBtn = $("renameChat"), clearChatBtn = $("clearChat"), exportChatBtn = $("exportChat"); const renameChatBtn = $("renameChat"), clearChatBtn = $("clearChat"), exportChatBtn = $("exportChat");
const activeChatTitle = $("activeChatTitle"), sessionUser = $("sessionUser"); const activeChatTitle = $("activeChatTitle"), sessionUser = $("sessionUser");
const chatModePill = $("chatModePill"), chatCharacterPill = $("chatCharacterPill"), chatUserPersonaPill = $("chatUserPersonaPill");
const personaList = $("personaList"), userPersonaList = $("userPersonaList"); const personaList = $("personaList"), userPersonaList = $("userPersonaList");
const personaForm = $("personaForm"), personaFormTitle = $("personaFormTitle"), personaTypeSelect = $("personaType"); const personaForm = $("personaForm"), personaFormTitle = $("personaFormTitle"), personaTypeSelect = $("personaType");
const personaNameInput = $("personaName"), personaPronounsInput = $("personaPronouns"), personaAppearanceInput = $("personaAppearance"); const personaNameInput = $("personaName"), personaPronounsInput = $("personaPronouns"), personaAppearanceInput = $("personaAppearance");
const personaBackgroundInput = $("personaBackground"), personaDetailsInput = $("personaDetails"); const personaBackgroundInput = $("personaBackground"), personaDetailsInput = $("personaDetails");
const personaExamplesField = $("personaExamplesField"), personaExampleDialoguesInput = $("personaExampleDialogues"); const personaExamplesField = $("personaExamplesField"), personaExampleDialoguesInput = $("personaExampleDialogues");
const personaFormNotice = $("personaFormNotice"); const personaFormNotice = $("personaFormNotice");
const newPersonaBtn = $("newPersona"), newUserPersonaBtn = $("newUserPersona"), clearPersonaBtn = $("clearPersona"), clearUserPersonaBtn = $("clearUserPersona"); const roleplayNewPersonaBtn = $("roleplayNewPersona"), clearUserPersonaBtn = $("clearUserPersona");
const activePersonaStatus = $("activePersonaStatus"), activeUserPersonaStatus = $("activeUserPersonaStatus"); const activePersonaStatus = $("activePersonaStatus"), activeUserPersonaStatus = $("activeUserPersonaStatus");
const personaModal = $("personaModal"), personaCloseBtn = $("personaClose"), personaMenuButton = $("personaMenuButton"), personaPopover = $("personaPopover"); const personaModal = $("personaModal"), personaCloseBtn = $("personaClose"), personaMenuButton = $("personaMenuButton"), personaPopover = $("personaPopover");
const roleplayStarterModal = $("roleplayStarterModal"), roleplayStarterClose = $("roleplayStarterClose"), roleplayStarterCancel = $("roleplayStarterCancel"), roleplayStarterConfirm = $("roleplayStarterConfirm");
const roleplayStarterNotice = $("roleplayStarterNotice"), roleplayCharacterSelect = $("roleplayCharacterSelect"), roleplayUserPersonaSelect = $("roleplayUserPersonaSelect");
const popupModal = $("popupModal"), popupCloseBtn = $("popupClose"), popupCancelBtn = $("popupCancel"), popupConfirmBtn = $("popupConfirm"); const popupModal = $("popupModal"), popupCloseBtn = $("popupClose"), popupCancelBtn = $("popupCancel"), popupConfirmBtn = $("popupConfirm");
const popupTitle = $("popupTitle"), popupEyebrow = $("popupEyebrow"), popupDescription = $("popupDescription"), popupField = $("popupField"); const popupTitle = $("popupTitle"), popupEyebrow = $("popupEyebrow"), popupDescription = $("popupDescription"), popupField = $("popupField");
const popupInputLabel = $("popupInputLabel"), popupInput = $("popupInput"); const popupInputLabel = $("popupInputLabel"), popupInput = $("popupInput");
const authScreens = document.querySelectorAll(".auth-screen"), toggleButtons = document.querySelectorAll(".auth-toggle .toggle"); const authScreens = document.querySelectorAll(".auth-screen"), toggleButtons = document.querySelectorAll(".auth-toggle .toggle");
const themeNameTargets = document.querySelectorAll("[data-theme-name]"), themeLogoTargets = document.querySelectorAll("[data-theme-logo]"); const themeNameTargets = document.querySelectorAll("[data-theme-name]"), themeLogoTargets = document.querySelectorAll("[data-theme-logo]");
const requestedChatId = Number(new URLSearchParams(window.location.search).get("chat")) || null;
let isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = ""; let isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = "";
let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = []; let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = [];
let activePersonaId = null, activeUserPersonaId = null, currentSummary = null, publishedPersonaIds = new Set(); let activeUserPersonaId = null, currentSummary = null, publishedPersonaIds = new Set();
let popupResolver = null, popupMode = null, popupLastFocus = null; let popupResolver = null, popupMode = null, popupLastFocus = null;
let roleplayPresetCharacterId = null;
const themes = { const themes = {
"fakegpt": {name: "FakeGPT", short: "FG"}, "fakegpt": {name: "FakeGPT", short: "FG"},
@@ -112,6 +117,7 @@ function addMessage(content, isUser = false, isLoading = false) {
function renderMessages() { function renderMessages() {
messagesDiv.innerHTML = ""; messagesDiv.innerHTML = "";
currentMessages.forEach((msg) => addMessage(msg.content, msg.role === "user")); currentMessages.forEach((msg) => addMessage(msg.content, msg.role === "user"));
updateChatParticipants();
updateChatActionState(); updateChatActionState();
} }
@@ -123,6 +129,45 @@ function updateWorkspaceCopy() {
if (sessionUser) { if (sessionUser) {
sessionUser.textContent = currentUsername || "-"; sessionUser.textContent = currentUsername || "-";
} }
updateChatParticipants();
}
function setRoleplayStarterNotice(message = "", state = "") {
if (!roleplayStarterNotice) return;
if (!message) {
roleplayStarterNotice.textContent = "";
roleplayStarterNotice.className = "status hidden";
return;
}
roleplayStarterNotice.textContent = message;
roleplayStarterNotice.className = state ? `status ${state}` : "status";
}
function updateChatParticipants() {
const chat = getChatById(activeChatId);
if (!chatModePill || !chatCharacterPill || !chatUserPersonaPill) return;
if (!chat) {
chatModePill.textContent = "No chat selected";
chatCharacterPill.classList.add("hidden");
chatUserPersonaPill.classList.add("hidden");
return;
}
if (chat.assistant_persona_id) {
chatModePill.textContent = "Roleplay mode";
chatCharacterPill.textContent = `Character: ${chat.assistant_persona_name || chat.title}`;
chatCharacterPill.classList.remove("hidden");
if (chat.user_persona_id) {
chatUserPersonaPill.textContent = `You as: ${chat.user_persona_name || "User persona"}`;
chatUserPersonaPill.classList.remove("hidden");
} else {
chatUserPersonaPill.textContent = "You as: yourself";
chatUserPersonaPill.classList.remove("hidden");
}
return;
}
chatModePill.textContent = "Assistant mode";
chatCharacterPill.classList.add("hidden");
chatUserPersonaPill.classList.add("hidden");
} }
function updateContextRail() { function updateContextRail() {
@@ -203,8 +248,9 @@ function applyTheme(themeKey, persist = true) {
} }
function updateChatActionState() { function updateChatActionState() {
const hasChat = Boolean(activeChatId); const activeChat = getChatById(activeChatId);
renameChatBtn.disabled = !hasChat; const hasChat = Boolean(activeChat);
renameChatBtn.disabled = !hasChat || Boolean(activeChat?.assistant_persona_id);
clearChatBtn.disabled = !hasChat; clearChatBtn.disabled = !hasChat;
exportChatBtn.disabled = !hasChat || currentMessages.length === 0; exportChatBtn.disabled = !hasChat || currentMessages.length === 0;
} }
@@ -221,11 +267,18 @@ function renderChatList() {
return; return;
} }
filtered.forEach((chat) => { filtered.forEach((chat) => {
const item = document.createElement("div"), title = document.createElement("span"), remove = document.createElement("button"); const item = document.createElement("div"), content = document.createElement("div"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button");
item.className = `chat-item${chat.id === activeChatId ? " active" : ""}`; item.className = `chat-item${chat.id === activeChatId ? " active" : ""}`;
title.textContent = chat.title; remove.type = "button"; remove.textContent = "Delete"; content.className = "chat-item-copy";
title.textContent = chat.title;
meta.className = "chat-item-meta";
meta.textContent = chat.assistant_persona_id
? `Roleplay${chat.user_persona_name ? ` · ${chat.user_persona_name}` : ""}`
: "Assistant chat";
remove.type = "button"; remove.textContent = "Delete";
remove.addEventListener("click", async (event) => { event.stopPropagation(); await deleteChat(chat.id); }); remove.addEventListener("click", async (event) => { event.stopPropagation(); await deleteChat(chat.id); });
item.append(title, remove); content.append(title, meta);
item.append(content, remove);
item.addEventListener("click", () => setActiveChat(chat.id)); item.addEventListener("click", () => setActiveChat(chat.id));
chatList.appendChild(item); chatList.appendChild(item);
}); });
@@ -237,10 +290,13 @@ function updateActivePersonaStatus(items, activeId, statusEl, emptyText, prefix)
} }
function setActivePersonaStatus() { function setActivePersonaStatus() {
updateActivePersonaStatus(assistantPersonas, activePersonaId, activePersonaStatus, "No AI persona equipped.", "AI persona equipped"); if (activePersonaStatus) {
updateActivePersonaStatus(userPersonas, activeUserPersonaId, activeUserPersonaStatus, "No user persona set.", "User persona active"); activePersonaStatus.textContent = assistantPersonas.length
clearPersonaBtn.disabled = !activePersonaId; ? "Pick a character, choose a user persona, and start the roleplay."
clearUserPersonaBtn.disabled = !activeUserPersonaId; : "No characters yet. Create one to start a roleplay.";
}
updateActivePersonaStatus(userPersonas, activeUserPersonaId, activeUserPersonaStatus, "No default user persona set.", "Default user persona");
if (clearUserPersonaBtn) clearUserPersonaBtn.disabled = !activeUserPersonaId;
updateContextRail(); updateContextRail();
} }
@@ -250,14 +306,14 @@ function openPersonaForm(persona = null, personaType = "assistant") {
personaModal.classList.remove("hidden"); personaModal.classList.remove("hidden");
if (persona) { if (persona) {
editingPersonaId = persona.id; editingPersonaType = persona.persona_type || "assistant"; editingPersonaId = persona.id; editingPersonaType = persona.persona_type || "assistant";
personaFormTitle.textContent = `Edit ${editingPersonaType === "user" ? "your persona" : "AI persona"}`; personaFormTitle.textContent = `Edit ${editingPersonaType === "user" ? "your persona" : "AI Character"}`;
personaTypeSelect.value = editingPersonaType; personaTypeSelect.disabled = true; personaTypeSelect.value = editingPersonaType; personaTypeSelect.disabled = true;
personaNameInput.value = persona.name || ""; personaPronounsInput.value = persona.pronouns || ""; personaNameInput.value = persona.name || ""; personaPronounsInput.value = persona.pronouns || "";
personaAppearanceInput.value = persona.appearance || ""; personaBackgroundInput.value = persona.background || ""; personaDetailsInput.value = persona.details || ""; personaAppearanceInput.value = persona.appearance || ""; personaBackgroundInput.value = persona.background || ""; personaDetailsInput.value = persona.details || "";
personaExampleDialoguesInput.value = persona.example_dialogues || ""; personaExampleDialoguesInput.value = persona.example_dialogues || "";
} else { } else {
editingPersonaId = null; editingPersonaType = personaType; editingPersonaId = null; editingPersonaType = personaType;
personaFormTitle.textContent = `Create ${personaType === "user" ? "your persona" : "AI persona"}`; personaFormTitle.textContent = `Create ${personaType === "user" ? "your persona" : "AI Character"}`;
personaTypeSelect.value = personaType; personaTypeSelect.disabled = false; personaTypeSelect.value = personaType; personaTypeSelect.disabled = false;
personaNameInput.value = ""; personaPronounsInput.value = ""; personaAppearanceInput.value = ""; personaBackgroundInput.value = ""; personaDetailsInput.value = ""; personaNameInput.value = ""; personaPronounsInput.value = ""; personaAppearanceInput.value = ""; personaBackgroundInput.value = ""; personaDetailsInput.value = "";
personaExampleDialoguesInput.value = ""; personaExampleDialoguesInput.value = "";
@@ -269,6 +325,45 @@ function openPersonaForm(persona = null, personaType = "assistant") {
function closePersonaForm() { personaModal.classList.add("hidden"); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); } function closePersonaForm() { personaModal.classList.add("hidden"); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); }
function closePersonaPopover() { personaPopover.classList.add("hidden"); personaMenuButton.setAttribute("aria-expanded", "false"); } function closePersonaPopover() { personaPopover.classList.add("hidden"); personaMenuButton.setAttribute("aria-expanded", "false"); }
function populateRoleplayStarter() {
if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return;
roleplayCharacterSelect.innerHTML = "";
assistantPersonas.forEach((persona) => {
const option = document.createElement("option");
option.value = String(persona.id);
option.textContent = persona.name;
option.selected = persona.id === roleplayPresetCharacterId;
roleplayCharacterSelect.appendChild(option);
});
roleplayUserPersonaSelect.innerHTML = "";
const noneOption = document.createElement("option");
noneOption.value = "";
noneOption.textContent = "No user persona";
roleplayUserPersonaSelect.appendChild(noneOption);
userPersonas.forEach((persona) => {
const option = document.createElement("option");
option.value = String(persona.id);
option.textContent = persona.name;
option.selected = persona.id === activeUserPersonaId;
roleplayUserPersonaSelect.appendChild(option);
});
}
function openRoleplayStarter(characterId = null) {
closePersonaPopover();
closeModelPopover();
roleplayPresetCharacterId = characterId;
populateRoleplayStarter();
setRoleplayStarterNotice("");
roleplayStarterModal.classList.remove("hidden");
if (roleplayCharacterSelect.options.length) roleplayCharacterSelect.focus();
}
function closeRoleplayStarter() {
if (!roleplayStarterModal) return;
roleplayStarterModal.classList.add("hidden");
roleplayPresetCharacterId = null;
roleplayStarterConfirm.disabled = false;
setRoleplayStarterNotice("");
}
function closeModelPopover() { function closeModelPopover() {
modelPopover.classList.add("hidden"); modelPopover.classList.add("hidden");
modelMenuButton.setAttribute("aria-expanded", "false"); modelMenuButton.setAttribute("aria-expanded", "false");
@@ -330,6 +425,10 @@ function promptPopup(options) {
} }
function renderPersonaList(items, activeId, listElement, personaType) { function renderPersonaList(items, activeId, listElement, personaType) {
if (!listElement) {
setActivePersonaStatus();
return;
}
listElement.innerHTML = ""; listElement.innerHTML = "";
if (!items.length) { if (!items.length) {
const empty = document.createElement("p"); const empty = document.createElement("p");
@@ -342,10 +441,10 @@ function renderPersonaList(items, activeId, listElement, personaType) {
items.forEach((persona) => { items.forEach((persona) => {
const item = document.createElement("div"), titleRow = document.createElement("div"), title = document.createElement("h4"); const item = document.createElement("div"), titleRow = document.createElement("div"), title = document.createElement("h4");
const tag = document.createElement("span"), meta = document.createElement("p"), actions = document.createElement("div"); const tag = document.createElement("span"), meta = document.createElement("p"), actions = document.createElement("div");
item.className = `persona-item${persona.id === activeId ? " active" : ""}`; titleRow.className = "persona-title-row"; actions.className = "persona-item-actions"; item.className = `persona-item${personaType === "user" && persona.id === activeId ? " active" : ""}`; titleRow.className = "persona-title-row"; actions.className = "persona-item-actions";
tag.className = `persona-role-tag ${personaType === "user" ? "user" : "ai"}`; tag.textContent = personaType === "user" ? "You" : "AI"; tag.className = `persona-role-tag ${personaType === "user" ? "user" : "ai"}`; tag.textContent = personaType === "user" ? "You" : "AI";
title.textContent = persona.name; meta.textContent = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a"; title.textContent = persona.name; meta.textContent = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a";
[["Equip", () => equipPersona(persona.id, personaType), persona.id === activeId], [[personaType === "assistant" ? "Start roleplay" : "Set default", () => equipPersona(persona.id, personaType), personaType === "user" && persona.id === activeId],
["Edit", () => openPersonaForm(persona), false], ["Edit", () => openPersonaForm(persona), false],
[publishedPersonaIds.has(persona.id) ? "Update listing" : "Publish", () => publishPersona(persona.id), false], [publishedPersonaIds.has(persona.id) ? "Update listing" : "Publish", () => publishPersona(persona.id), false],
["Delete", () => deletePersona(persona.id), false]] ["Delete", () => deletePersona(persona.id), false]]
@@ -371,26 +470,30 @@ async function loadPersonas() {
const res = await get("/personas"); const res = await get("/personas");
if (res.error) return setNotice(res.error, "error"); if (res.error) return setNotice(res.error, "error");
assistantPersonas = res.assistantPersonas || []; userPersonas = res.userPersonas || []; assistantPersonas = res.assistantPersonas || []; userPersonas = res.userPersonas || [];
activePersonaId = res.activePersonaId || null; activeUserPersonaId = res.activeUserPersonaId || null; publishedPersonaIds = new Set(res.publishedPersonaIds || []); activeUserPersonaId = res.activeUserPersonaId || null; publishedPersonaIds = new Set(res.publishedPersonaIds || []);
renderPersonaList(assistantPersonas, activePersonaId, personaList, "assistant");
renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user"); renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user");
populateRoleplayStarter();
setActivePersonaStatus();
updateWorkspaceCopy(); updateWorkspaceCopy();
} }
async function equipPersona(id, type) { async function equipPersona(id, type) {
const res = await post(type === "user" ? `/personas/${id}/equip-user` : `/personas/${id}/equip`, {}); if (type === "assistant") {
openRoleplayStarter(id);
return;
}
const res = await post(`/personas/${id}/equip-user`, {});
if (res.error) return setNotice(res.error, "error"); if (res.error) return setNotice(res.error, "error");
if (type === "user") activeUserPersonaId = id; else activePersonaId = id; activeUserPersonaId = id;
renderPersonaList(assistantPersonas, activePersonaId, personaList, "assistant");
renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user"); renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user");
setNotice(type === "user" ? "User persona updated." : "AI persona updated.", "success"); setNotice("User persona updated.", "success");
} }
async function clearPersona(type) { async function clearPersona(type) {
const res = await post(type === "user" ? "/personas/user/clear" : "/personas/clear", {}); if (type !== "user") return;
const res = await post("/personas/user/clear", {});
if (res.error) return setNotice(res.error, "error"); if (res.error) return setNotice(res.error, "error");
if (type === "user") activeUserPersonaId = null; else activePersonaId = null; activeUserPersonaId = null;
renderPersonaList(assistantPersonas, activePersonaId, personaList, "assistant");
renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user"); renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user");
setNotice("Persona cleared.", "success"); setNotice("Persona cleared.", "success");
} }
@@ -409,14 +512,16 @@ async function savePersona() {
const res = editingPersonaId ? await put(`/personas/${editingPersonaId}`, payload) : await post("/personas", payload); const res = editingPersonaId ? await put(`/personas/${editingPersonaId}`, payload) : await post("/personas", payload);
if (res.error) return setPersonaFormNotice(res.error, "error"); if (res.error) return setPersonaFormNotice(res.error, "error");
const wasEditing = Boolean(editingPersonaId); const wasEditing = Boolean(editingPersonaId);
await loadPersonas(); closePersonaForm(); setNotice(wasEditing ? "Persona updated." : "Persona created.", "success"); await loadPersonas();
if (payload.personaType === "assistant") await loadChatSessions();
closePersonaForm(); setNotice(wasEditing ? "Persona updated." : "Persona created.", "success");
} }
async function deletePersona(id) { async function deletePersona(id) {
const persona = assistantPersonas.find((item) => item.id === id) || userPersonas.find((item) => item.id === id); const persona = assistantPersonas.find((item) => item.id === id) || userPersonas.find((item) => item.id === id);
if (!persona) return; if (!persona) return;
const confirmed = await confirmPopup({ const confirmed = await confirmPopup({
eyebrow: persona.persona_type === "user" ? "Your persona" : "AI persona", eyebrow: persona.persona_type === "user" ? "Your persona" : "AI Character",
title: "Delete persona", title: "Delete persona",
description: `Delete "${persona.name}"? This removes it from your library and any active slot.`, description: `Delete "${persona.name}"? This removes it from your library and any active slot.`,
confirmLabel: "Delete", confirmLabel: "Delete",
@@ -425,7 +530,9 @@ async function deletePersona(id) {
if (!confirmed) return; if (!confirmed) return;
const res = await del(`/personas/${id}`); const res = await del(`/personas/${id}`);
if (res.error) return setNotice(res.error, "error"); if (res.error) return setNotice(res.error, "error");
await loadPersonas(); setNotice("Persona deleted.", "success"); await loadPersonas();
if (persona.persona_type === "assistant") await loadChatSessions();
setNotice("Persona deleted.", "success");
} }
async function publishPersona(id) { const res = await post(`/personas/${id}/publish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona published.", "success"); } async function publishPersona(id) { const res = await post(`/personas/${id}/publish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona published.", "success"); }
async function unpublishPersona(id) { const res = await post(`/personas/${id}/unpublish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona unpublished.", "success"); } async function unpublishPersona(id) { const res = await post(`/personas/${id}/unpublish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona unpublished.", "success"); }
@@ -446,6 +553,8 @@ async function setActiveChat(id) {
updateWorkspaceCopy(); renderChatList(); updateChatActionState(); updateWorkspaceCopy(); renderChatList(); updateChatActionState();
const res = await get(`/chats/${id}/messages`); const res = await get(`/chats/${id}/messages`);
if (res.error) return setNotice(res.error, "error"); if (res.error) return setNotice(res.error, "error");
const existing = getChatById(id);
if (res.chat && existing) Object.assign(existing, res.chat);
currentMessages = res.messages || []; renderMessages(); currentMessages = res.messages || []; renderMessages();
} }
@@ -453,7 +562,10 @@ async function loadChatSessions() {
const res = await get("/chats"); const res = await get("/chats");
if (res.error) return setNotice(res.error, "error"); if (res.error) return setNotice(res.error, "error");
chatSessions = res.chats || []; renderChatList(); chatSessions = res.chats || []; renderChatList();
if (chatSessions.length) return setActiveChat(activeChatId && getChatById(activeChatId) ? activeChatId : chatSessions[0].id); if (chatSessions.length) {
const preferredChatId = (requestedChatId && getChatById(requestedChatId)) ? requestedChatId : activeChatId && getChatById(activeChatId) ? activeChatId : chatSessions[0].id;
return setActiveChat(preferredChatId);
}
return createNewChat(); return createNewChat();
} }
@@ -463,6 +575,29 @@ async function createNewChat() {
chatSessions.unshift(res.chat); await loadSummary(); renderChatList(); await setActiveChat(res.chat.id); setNotice("New chat created.", "success"); chatSessions.unshift(res.chat); await loadSummary(); renderChatList(); await setActiveChat(res.chat.id); setNotice("New chat created.", "success");
} }
async function startRoleplay() {
const assistantPersonaId = Number(roleplayCharacterSelect.value);
const userPersonaId = roleplayUserPersonaSelect.value ? Number(roleplayUserPersonaSelect.value) : null;
if (!assistantPersonaId) {
return setRoleplayStarterNotice("Choose a character to start the roleplay.", "error");
}
roleplayStarterConfirm.disabled = true;
setRoleplayStarterNotice("Starting roleplay...");
const res = await post("/roleplays/start", {
assistantPersonaId,
userPersonaId,
model: modelSelect.value
});
roleplayStarterConfirm.disabled = false;
if (res.error || !res.chat) {
return setRoleplayStarterNotice(res.error || "Unable to start roleplay.", "error");
}
closeRoleplayStarter();
await loadChatSessions();
await setActiveChat(res.chat.id);
setNotice(res.generatedInitialMessage ? "Roleplay started." : "Roleplay reopened.", "success");
}
async function renameChat(id) { async function renameChat(id) {
const chat = getChatById(id); const chat = getChatById(id);
const nextTitle = chat ? await promptPopup({ const nextTitle = chat ? await promptPopup({
@@ -576,7 +711,7 @@ async function sendMessage() {
if (!message) return; if (!message) return;
if (!activeChatId) await createNewChat(); if (!activeChatId) await createNewChat();
const currentChat = getChatById(activeChatId); const currentChat = getChatById(activeChatId);
if (currentChat && currentChat.title === "New chat") { if (currentChat && !currentChat.assistant_persona_id && currentChat.title === "New chat") {
const autoTitle = message.length > 40 ? `${message.slice(0, 40)}...` : message; const autoTitle = message.length > 40 ? `${message.slice(0, 40)}...` : message;
currentChat.title = autoTitle; updateWorkspaceCopy(); renderChatList(); await put(`/chats/${activeChatId}`, {title: autoTitle}); currentChat.title = autoTitle; updateWorkspaceCopy(); renderChatList(); await put(`/chats/${activeChatId}`, {title: autoTitle});
} }
@@ -621,7 +756,7 @@ registerForm.addEventListener("submit", (event) => { event.preventDefault(); voi
$("logout").addEventListener("click", async () => { $("logout").addEventListener("click", async () => {
await post("/logout", {}); chatDiv.style.display = "none"; authDiv.style.display = "grid"; await post("/logout", {}); chatDiv.style.display = "none"; authDiv.style.display = "grid";
activeChatId = null; currentUsername = ""; currentSummary = null; chatSessions = []; currentMessages = []; assistantPersonas = []; userPersonas = []; activeChatId = null; currentUsername = ""; currentSummary = null; chatSessions = []; currentMessages = []; assistantPersonas = []; userPersonas = [];
activePersonaId = null; activeUserPersonaId = null; publishedPersonaIds = new Set(); messagesDiv.innerHTML = ""; renderChatList(); updateChatActionState(); activeUserPersonaId = null; publishedPersonaIds = new Set(); messagesDiv.innerHTML = ""; renderChatList(); updateChatActionState();
showAuthScreen("login"); setAuthMessage("Logged out.", "success"); setNotice("Ready."); showAuthScreen("login"); setAuthMessage("Logged out.", "success"); setNotice("Ready.");
}); });
sendBtn.addEventListener("click", () => { void sendMessage(); }); sendBtn.addEventListener("click", () => { void sendMessage(); });
@@ -631,8 +766,8 @@ newChatBtn.addEventListener("click", () => { void createNewChat(); });
renameChatBtn.addEventListener("click", () => { if (activeChatId) void renameChat(activeChatId); }); renameChatBtn.addEventListener("click", () => { if (activeChatId) void renameChat(activeChatId); });
clearChatBtn.addEventListener("click", () => { if (activeChatId) void clearChat(activeChatId); }); exportChatBtn.addEventListener("click", exportCurrentChat); clearChatBtn.addEventListener("click", () => { if (activeChatId) void clearChat(activeChatId); }); exportChatBtn.addEventListener("click", exportCurrentChat);
chatSearchInput.addEventListener("input", renderChatList); modelSelect.addEventListener("change", () => { updateModelHelp(); updateContextRail(); }); chatSearchInput.addEventListener("input", renderChatList); modelSelect.addEventListener("change", () => { updateModelHelp(); updateContextRail(); });
newPersonaBtn.addEventListener("click", () => openPersonaForm()); newUserPersonaBtn.addEventListener("click", () => openPersonaForm(null, "user")); roleplayNewPersonaBtn.addEventListener("click", () => openPersonaForm(null, "assistant"));
clearPersonaBtn.addEventListener("click", () => { void clearPersona("assistant"); }); clearUserPersonaBtn.addEventListener("click", () => { void clearPersona("user"); }); clearUserPersonaBtn.addEventListener("click", () => { void clearPersona("user"); });
personaForm.addEventListener("submit", (event) => { event.preventDefault(); void savePersona(); }); personaCloseBtn.addEventListener("click", closePersonaForm); personaForm.addEventListener("submit", (event) => { event.preventDefault(); void savePersona(); }); personaCloseBtn.addEventListener("click", closePersonaForm);
personaTypeSelect.addEventListener("change", () => { personaTypeSelect.addEventListener("change", () => {
const isAssistant = personaTypeSelect.value === "assistant"; const isAssistant = personaTypeSelect.value === "assistant";
@@ -640,6 +775,10 @@ personaTypeSelect.addEventListener("change", () => {
if (!isAssistant) personaExampleDialoguesInput.value = ""; if (!isAssistant) personaExampleDialoguesInput.value = "";
}); });
personaModal.addEventListener("click", (event) => { if (event.target === personaModal) closePersonaForm(); }); personaModal.addEventListener("click", (event) => { if (event.target === personaModal) closePersonaForm(); });
roleplayStarterClose.addEventListener("click", closeRoleplayStarter);
roleplayStarterCancel.addEventListener("click", closeRoleplayStarter);
roleplayStarterConfirm.addEventListener("click", () => { void startRoleplay(); });
roleplayStarterModal.addEventListener("click", (event) => { if (event.target === roleplayStarterModal) closeRoleplayStarter(); });
popupConfirmBtn.addEventListener("click", () => { popupConfirmBtn.addEventListener("click", () => {
if (popupMode === "prompt") { if (popupMode === "prompt") {
closePopup(popupInput.value); closePopup(popupInput.value);
@@ -663,6 +802,7 @@ document.addEventListener("keydown", (event) => {
if (event.key === "Escape") { if (event.key === "Escape") {
closeModelPopover(); closeModelPopover();
closePersonaPopover(); closePersonaPopover();
closeRoleplayStarter();
closePersonaForm(); closePersonaForm();
closePopup(false); closePopup(false);
} }
+92 -2
View File
@@ -15,11 +15,11 @@
<div> <div>
<p class="eyebrow">Settings</p> <p class="eyebrow">Settings</p>
<h1>Preferences</h1> <h1>Preferences</h1>
<p class="subtitle">Manage account details and appearance.</p> <p class="subtitle">Manage your account, AI Characters, user personas, and appearance.</p>
</div> </div>
<div class="market-header-actions"> <div class="market-header-actions">
<a href="/" class="secondary-action">Back to chat</a> <a href="/" class="secondary-action">Back to chat</a>
<a href="/market" class="secondary-action">Persona Market</a> <a href="/market" class="secondary-action">Market</a>
</div> </div>
</header> </header>
@@ -28,6 +28,8 @@
<section class="settings-layout"> <section class="settings-layout">
<aside class="settings-nav" aria-label="Settings sections"> <aside class="settings-nav" aria-label="Settings sections">
<button class="settings-nav-item active" type="button" data-settings-view="personal">Personal</button> <button class="settings-nav-item active" type="button" data-settings-view="personal">Personal</button>
<button class="settings-nav-item" type="button" data-settings-view="my-ai-characters">My AI Characters</button>
<button class="settings-nav-item" type="button" data-settings-view="my-personas">My Personas</button>
<button class="settings-nav-item" type="button" data-settings-view="design">Design</button> <button class="settings-nav-item" type="button" data-settings-view="design">Design</button>
</aside> </aside>
@@ -82,6 +84,34 @@
</div> </div>
</section> </section>
<section class="settings-view" data-settings-panel="my-ai-characters">
<div class="settings-panel">
<div class="settings-section-header">
<div>
<p class="eyebrow">Manage</p>
<h2>My AI Characters</h2>
<p class="subtitle">Create, edit, publish, and delete your AI Characters.</p>
</div>
<button id="createAiCharacter" type="button" class="secondary-action">Create persona</button>
</div>
<div id="settingsAiCharacterList" class="persona-list"></div>
</div>
</section>
<section class="settings-view" data-settings-panel="my-personas">
<div class="settings-panel">
<div class="settings-section-header">
<div>
<p class="eyebrow">Manage</p>
<h2>My Personas</h2>
<p class="subtitle">Create and manage the user personas you can speak as in roleplay chats.</p>
</div>
<button id="createUserPersona" type="button" class="secondary-action">Create persona</button>
</div>
<div id="settingsUserPersonaList" class="persona-list"></div>
</div>
</section>
<section class="settings-view" data-settings-panel="design"> <section class="settings-view" data-settings-panel="design">
<div class="settings-panel settings-panel-compact"> <div class="settings-panel settings-panel-compact">
<div class="settings-section-header"> <div class="settings-section-header">
@@ -109,6 +139,66 @@
</div> </div>
</section> </section>
</div> </div>
<div id="settingsPersonaModal" class="modal-overlay hidden">
<div class="modal-card persona-modal-card">
<div class="modal-header">
<div class="persona-modal-heading">
<p class="eyebrow">Persona editor</p>
<h3 id="settingsPersonaFormTitle">Create persona</h3>
</div>
<button id="settingsPersonaClose" type="button" class="icon-button">Close</button>
</div>
<form id="settingsPersonaForm" class="persona-form">
<div class="persona-form-intro">
<p id="settingsPersonaHelper" class="persona-helper">Create a persona for roleplay chats.</p>
</div>
<p id="settingsPersonaFormNotice" class="status hidden"></p>
<div class="persona-form-grid">
<label class="field field-wide">
<span>Persona for</span>
<select id="settingsPersonaType">
<option value="assistant">AI Character</option>
<option value="user">Your persona</option>
</select>
</label>
<label class="field">
<span>Name</span>
<input id="settingsPersonaName" placeholder="Persona name" required/>
</label>
<label class="field">
<span>Pronouns</span>
<input id="settingsPersonaPronouns" placeholder="e.g. she/her, they/them"/>
</label>
<label class="field field-wide">
<span>Appearance</span>
<textarea id="settingsPersonaAppearance" rows="3" placeholder="Describe their look"></textarea>
</label>
<label class="field field-wide">
<span>Background</span>
<textarea id="settingsPersonaBackground" rows="3" placeholder="Backstory, history, role, or origins"></textarea>
</label>
<label class="field field-wide">
<span>Details</span>
<textarea id="settingsPersonaDetails" rows="4" placeholder="Personality traits, voice, goals, quirks, boundaries, style"></textarea>
</label>
<label id="settingsPersonaExamplesField" class="field field-wide hidden">
<span>Example dialogues</span>
<div class="dialogue-format-card" aria-hidden="true">
<p class="dialogue-format-label">Format</p>
<pre class="dialogue-format-example">{{character}}: Hello, my name is marc
{{user}}: Hi, Marc!
{{character}}: Nice to meet you</pre>
</div>
<textarea id="settingsPersonaExampleDialogues" rows="5" placeholder="Write example exchanges that define the character's tone, phrasing, pacing, and personality."></textarea>
</label>
</div>
<div class="persona-actions">
<button id="settingsPersonaSave" type="submit" class="primary-action">Save persona</button>
</div>
</form>
</div>
</div>
<script src="settings.js" defer></script> <script src="settings.js" defer></script>
</body> </body>
</html> </html>
+243 -12
View File
@@ -1,7 +1,6 @@
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const settingsNotice = $("settingsNotice"); const settingsNotice = $("settingsNotice");
const settingsThemeSelect = $("settingsThemeSelect"); const settingsThemeSelect = $("settingsThemeSelect");
const settingsThemeName = $("settingsThemeName");
const currentUsernameInput = $("currentUsername"); const currentUsernameInput = $("currentUsername");
const newUsernameInput = $("newUsername"); const newUsernameInput = $("newUsername");
const usernamePasswordInput = $("usernamePassword"); const usernamePasswordInput = $("usernamePassword");
@@ -12,6 +11,24 @@ const usernameForm = $("usernameForm");
const passwordForm = $("passwordForm"); const passwordForm = $("passwordForm");
const settingsNavItems = document.querySelectorAll("[data-settings-view]"); const settingsNavItems = document.querySelectorAll("[data-settings-view]");
const settingsPanels = document.querySelectorAll("[data-settings-panel]"); const settingsPanels = document.querySelectorAll("[data-settings-panel]");
const createAiCharacterBtn = $("createAiCharacter");
const createUserPersonaBtn = $("createUserPersona");
const settingsAiCharacterList = $("settingsAiCharacterList");
const settingsUserPersonaList = $("settingsUserPersonaList");
const settingsPersonaModal = $("settingsPersonaModal");
const settingsPersonaClose = $("settingsPersonaClose");
const settingsPersonaForm = $("settingsPersonaForm");
const settingsPersonaFormTitle = $("settingsPersonaFormTitle");
const settingsPersonaFormNotice = $("settingsPersonaFormNotice");
const settingsPersonaHelper = $("settingsPersonaHelper");
const settingsPersonaType = $("settingsPersonaType");
const settingsPersonaName = $("settingsPersonaName");
const settingsPersonaPronouns = $("settingsPersonaPronouns");
const settingsPersonaAppearance = $("settingsPersonaAppearance");
const settingsPersonaBackground = $("settingsPersonaBackground");
const settingsPersonaDetails = $("settingsPersonaDetails");
const settingsPersonaExamplesField = $("settingsPersonaExamplesField");
const settingsPersonaExampleDialogues = $("settingsPersonaExampleDialogues");
const themes = { const themes = {
"fakegpt": {name: "FakeGPT"}, "fakegpt": {name: "FakeGPT"},
@@ -22,6 +39,11 @@ const themes = {
"confusity": {name: "Confusity"} "confusity": {name: "Confusity"}
}; };
let assistantPersonas = [];
let userPersonas = [];
let publishedPersonaIds = new Set();
let editingPersonaId = null;
async function request(url, data, method = "POST") { async function request(url, data, method = "POST") {
try { try {
const res = await fetch(url, { const res = await fetch(url, {
@@ -35,34 +57,220 @@ async function request(url, data, method = "POST") {
} }
} }
const get = (url) => request(url, null, "GET");
const post = (url, data) => request(url, data, "POST");
const put = (url, data) => request(url, data, "PUT");
const del = (url) => request(url, null, "DELETE");
function setNotice(message, state = "") { function setNotice(message, state = "") {
settingsNotice.textContent = message; settingsNotice.textContent = message;
settingsNotice.className = state ? `status ${state}` : "status"; settingsNotice.className = state ? `status ${state}` : "status";
} }
function setPersonaNotice(message = "", state = "") {
if (!message) {
settingsPersonaFormNotice.textContent = "";
settingsPersonaFormNotice.className = "status hidden";
return;
}
settingsPersonaFormNotice.textContent = message;
settingsPersonaFormNotice.className = state ? `status ${state}` : "status";
}
function applyTheme(themeKey, persist = true) { function applyTheme(themeKey, persist = true) {
const nextTheme = themes[themeKey] ? themeKey : "fakegpt"; const nextTheme = themes[themeKey] ? themeKey : "fakegpt";
document.body.dataset.theme = nextTheme; document.body.dataset.theme = nextTheme;
document.title = "Settings"; document.title = "Settings";
settingsThemeSelect.value = nextTheme; settingsThemeSelect.value = nextTheme;
if (settingsThemeName) settingsThemeName.textContent = themes[nextTheme].name;
if (persist) localStorage.setItem("krishd-theme", nextTheme); if (persist) localStorage.setItem("krishd-theme", nextTheme);
} }
function setSettingsView(view) { function setSettingsView(view) {
settingsNavItems.forEach((item) => item.classList.toggle("active", item.dataset.settingsView === view)); settingsNavItems.forEach((item) => item.classList.toggle("active", item.dataset.settingsView === view));
settingsPanels.forEach((panel) => panel.classList.toggle("active", panel.dataset.settingsPanel === view)); settingsPanels.forEach((panel) => panel.classList.toggle("active", panel.dataset.settingsPanel === view));
const params = new URLSearchParams(window.location.search);
params.set("view", view);
history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`);
}
function formatMeta(persona) {
return persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a";
}
function syncPersonaExamplesField() {
const isAssistant = settingsPersonaType.value === "assistant";
settingsPersonaExamplesField.classList.toggle("hidden", !isAssistant);
settingsPersonaHelper.textContent = isAssistant
? "AI Characters define who the character is, how they speak, and what kind of scene they lead."
: "User personas define who you are when starting roleplay chats with AI Characters.";
$("settingsPersonaSave").textContent = isAssistant ? "Save AI Character" : "Save persona";
}
function openPersonaModal(persona = null, personaType = "assistant") {
editingPersonaId = persona ? persona.id : null;
settingsPersonaType.disabled = Boolean(persona);
settingsPersonaType.value = persona ? persona.persona_type : personaType;
settingsPersonaFormTitle.textContent = persona
? `Edit ${settingsPersonaType.value === "assistant" ? "AI Character" : "persona"}`
: `Create ${personaType === "assistant" ? "AI Character" : "persona"}`;
settingsPersonaName.value = persona?.name || "";
settingsPersonaPronouns.value = persona?.pronouns || "";
settingsPersonaAppearance.value = persona?.appearance || "";
settingsPersonaBackground.value = persona?.background || "";
settingsPersonaDetails.value = persona?.details || "";
settingsPersonaExampleDialogues.value = persona?.example_dialogues || "";
syncPersonaExamplesField();
setPersonaNotice("");
settingsPersonaModal.classList.remove("hidden");
settingsPersonaName.focus();
}
function closePersonaModal() {
settingsPersonaModal.classList.add("hidden");
editingPersonaId = null;
setPersonaNotice("");
}
function renderPersonaList(personas, listElement, type) {
listElement.innerHTML = "";
if (!personas.length) {
const empty = document.createElement("p");
empty.className = "status persona-status";
empty.textContent = type === "assistant"
? "No AI Characters yet. Create one to get started."
: "No user personas yet. Create one to talk as someone specific.";
listElement.appendChild(empty);
return;
}
personas.forEach((persona) => {
const item = document.createElement("div");
const titleRow = document.createElement("div");
const title = document.createElement("h4");
const tag = document.createElement("span");
const meta = document.createElement("p");
const actions = document.createElement("div");
item.className = "persona-item";
titleRow.className = "persona-title-row";
actions.className = "persona-item-actions";
tag.className = `persona-role-tag ${type === "assistant" ? "ai" : "user"}`;
tag.textContent = type === "assistant" ? "AI Character" : "User";
title.textContent = persona.name;
meta.textContent = formatMeta(persona);
[
["Edit", () => openPersonaModal(persona)],
[type === "assistant" && publishedPersonaIds.has(persona.id) ? "Update listing" : type === "assistant" ? "Publish" : null, () => publishPersona(persona.id)],
["Delete", () => deletePersona(persona.id)]
]
.filter(([label]) => Boolean(label))
.forEach(([label, handler]) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.addEventListener("click", handler);
actions.appendChild(button);
});
if (type === "assistant" && publishedPersonaIds.has(persona.id)) {
const unpublishButton = document.createElement("button");
unpublishButton.type = "button";
unpublishButton.textContent = "Unpublish";
unpublishButton.addEventListener("click", () => unpublishPersona(persona.id));
actions.appendChild(unpublishButton);
}
titleRow.append(title, tag);
item.append(titleRow, meta, actions);
listElement.appendChild(item);
});
} }
async function loadProfile() { async function loadProfile() {
const res = await request("/settings/profile", null, "GET"); const res = await get("/settings/profile");
if (res.error) {
setNotice(res.error, "error");
return false;
}
currentUsernameInput.value = res.username || "";
newUsernameInput.value = res.username || "";
return true;
}
async function loadPersonas() {
const res = await get("/personas");
if (res.error) {
setNotice(res.error, "error");
return false;
}
assistantPersonas = res.assistantPersonas || [];
userPersonas = res.userPersonas || [];
publishedPersonaIds = new Set(res.publishedPersonaIds || []);
renderPersonaList(assistantPersonas, settingsAiCharacterList, "assistant");
renderPersonaList(userPersonas, settingsUserPersonaList, "user");
return true;
}
async function savePersona() {
const payload = {
personaType: settingsPersonaType.value,
name: settingsPersonaName.value.trim(),
pronouns: settingsPersonaPronouns.value.trim(),
appearance: settingsPersonaAppearance.value.trim(),
background: settingsPersonaBackground.value.trim(),
details: settingsPersonaDetails.value.trim(),
exampleDialogues: settingsPersonaType.value === "assistant" ? settingsPersonaExampleDialogues.value.trim() : ""
};
if (!payload.name) {
setPersonaNotice("Name is required.", "error");
return;
}
const res = editingPersonaId
? await put(`/personas/${editingPersonaId}`, payload)
: await post("/personas", payload);
if (res.error) {
setPersonaNotice(res.error, "error");
return;
}
await loadPersonas();
closePersonaModal();
setSettingsView(payload.personaType === "assistant" ? "my-ai-characters" : "my-personas");
setNotice(editingPersonaId ? "Persona updated." : "Persona created.", "success");
}
async function deletePersona(id) {
const res = await del(`/personas/${id}`);
if (res.error) { if (res.error) {
setNotice(res.error, "error"); setNotice(res.error, "error");
return; return;
} }
currentUsernameInput.value = res.username || ""; await loadPersonas();
newUsernameInput.value = res.username || ""; setNotice("Persona deleted.", "success");
setNotice("Settings loaded."); }
async function publishPersona(id) {
const res = await post(`/personas/${id}/publish`, {});
if (res.error) {
setNotice(res.error, "error");
return;
}
await loadPersonas();
setNotice("AI Character published.", "success");
}
async function unpublishPersona(id) {
const res = await post(`/personas/${id}/unpublish`, {});
if (res.error) {
setNotice(res.error, "error");
return;
}
await loadPersonas();
setNotice("AI Character unpublished.", "success");
} }
usernameForm.addEventListener("submit", async (event) => { usernameForm.addEventListener("submit", async (event) => {
@@ -87,17 +295,17 @@ usernameForm.addEventListener("submit", async (event) => {
passwordForm.addEventListener("submit", async (event) => { passwordForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const currentPassword = currentPasswordInput.value; const currentPassword = currentPasswordInput.value;
const newPassword = newPasswordInput.value; const nextPassword = newPasswordInput.value;
const repeatPassword = repeatPasswordInput.value; const repeatPassword = repeatPasswordInput.value;
if (!currentPassword || !newPassword || !repeatPassword) { if (!currentPassword || !nextPassword || !repeatPassword) {
setNotice("Fill in all password fields.", "error"); setNotice("Fill in all password fields.", "error");
return; return;
} }
if (newPassword !== repeatPassword) { if (nextPassword !== repeatPassword) {
setNotice("New passwords do not match.", "error"); setNotice("New passwords do not match.", "error");
return; return;
} }
const res = await request("/settings/password", {currentPassword, newPassword}, "PUT"); const res = await request("/settings/password", {currentPassword, newPassword: nextPassword}, "PUT");
if (res.error) { if (res.error) {
setNotice(res.error, "error"); setNotice(res.error, "error");
return; return;
@@ -114,9 +322,32 @@ settingsThemeSelect.addEventListener("change", (event) => {
}); });
settingsNavItems.forEach((item) => item.addEventListener("click", () => setSettingsView(item.dataset.settingsView))); settingsNavItems.forEach((item) => item.addEventListener("click", () => setSettingsView(item.dataset.settingsView)));
createAiCharacterBtn.addEventListener("click", () => openPersonaModal(null, "assistant"));
createUserPersonaBtn.addEventListener("click", () => openPersonaModal(null, "user"));
settingsPersonaClose.addEventListener("click", closePersonaModal);
settingsPersonaModal.addEventListener("click", (event) => {
if (event.target === settingsPersonaModal) closePersonaModal();
});
settingsPersonaType.addEventListener("change", syncPersonaExamplesField);
settingsPersonaForm.addEventListener("submit", (event) => {
event.preventDefault();
void savePersona();
});
window.addEventListener("load", async () => { window.addEventListener("load", async () => {
applyTheme(localStorage.getItem("krishd-theme") || "fakegpt", false); applyTheme(localStorage.getItem("krishd-theme") || "fakegpt", false);
setSettingsView("personal"); const params = new URLSearchParams(window.location.search);
await loadProfile(); const view = params.get("view") || "personal";
setSettingsView(view);
const [profileOk, personasOk] = await Promise.all([loadProfile(), loadPersonas()]);
if (profileOk && personasOk) setNotice("Settings loaded.");
const create = params.get("create");
if (create === "assistant") {
setSettingsView("my-ai-characters");
openPersonaModal(null, "assistant");
} else if (create === "user") {
setSettingsView("my-personas");
openPersonaModal(null, "user");
}
}); });
+70 -1
View File
@@ -384,6 +384,10 @@ h2 {
font-family: "Space Grotesk", sans-serif; font-family: "Space Grotesk", sans-serif;
} }
.chat-mode-pill {
font-weight: 700;
}
.auth-toggle, .auth-toggle,
.persona-form, .persona-form,
.input-shell, .input-shell,
@@ -589,6 +593,7 @@ select:focus {
.chat-title-block, .chat-title-block,
.chat-toolbar, .chat-toolbar,
.chat-header-actions, .chat-header-actions,
.sidebar-header-actions,
.market-header, .market-header,
.market-header-actions, .market-header-actions,
.persona-title-row, .persona-title-row,
@@ -663,6 +668,19 @@ select:focus {
line-height: 1.35; line-height: 1.35;
} }
.chat-item-copy {
min-width: 0;
display: grid;
gap: 4px;
}
.chat-item-meta {
color: var(--text-muted);
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.chat-item button, .chat-item button,
.icon-button { .icon-button {
padding: 8px 12px; padding: 8px 12px;
@@ -792,6 +810,18 @@ select:focus {
gap: 4px; gap: 4px;
} }
.chat-participants {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
}
.sidebar-header-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
.chat-toolbar { .chat-toolbar {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
@@ -1259,7 +1289,7 @@ select:focus {
position: absolute; position: absolute;
top: calc(100% + 10px); top: calc(100% + 10px);
right: 0; right: 0;
width: min(560px, 88vw); width: min(460px, 88vw);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 16px; padding: 16px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -1273,6 +1303,40 @@ select:focus {
gap: 12px; gap: 12px;
} }
.character-library-card {
width: min(760px, 95vw);
display: grid;
gap: 18px;
}
.character-library-intro {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 16px 18px;
border-radius: 16px;
background: rgba(24, 32, 31, 0.04);
border: 1px solid rgba(24, 32, 31, 0.08);
}
.character-library-list {
max-height: min(56vh, 520px);
}
.roleplay-starter-card {
width: min(620px, 95vw);
display: grid;
gap: 18px;
}
.roleplay-starter-intro {
padding: 16px 18px;
border-radius: 16px;
background: rgba(24, 32, 31, 0.04);
border: 1px solid rgba(24, 32, 31, 0.08);
}
.persona-status { .persona-status {
text-align: left; text-align: left;
} }
@@ -1310,6 +1374,10 @@ select:focus {
flex-wrap: wrap; flex-wrap: wrap;
} }
.market-preview-user-field {
width: 100%;
}
.persona-item-actions button { .persona-item-actions button {
padding: 8px 11px; padding: 8px 11px;
font-size: 0.84rem; font-size: 0.84rem;
@@ -1515,6 +1583,7 @@ select:focus {
margin: 0; margin: 0;
color: var(--text); color: var(--text);
line-height: 1.5; line-height: 1.5;
white-space: pre-wrap;
} }
.settings-shell { .settings-shell {
+365 -80
View File
@@ -131,6 +131,10 @@ db.prepare(`
TEXT TEXT
NOT NOT
NULL, NULL,
assistant_persona_id
INTEGER,
user_persona_id
INTEGER,
created_at created_at
DATETIME DATETIME
DEFAULT DEFAULT
@@ -138,7 +142,23 @@ db.prepare(`
updated_at updated_at
DATETIME DATETIME
DEFAULT DEFAULT
CURRENT_TIMESTAMP CURRENT_TIMESTAMP,
FOREIGN
KEY
(
assistant_persona_id
) REFERENCES personas
(
id
) ON DELETE SET NULL,
FOREIGN
KEY
(
user_persona_id
) REFERENCES personas
(
id
) ON DELETE SET NULL
) )
`).run(); `).run();
db.prepare(` db.prepare(`
@@ -311,13 +331,26 @@ const getLegacyChatsStmt = db.prepare(`
ORDER BY datetime(created_at) ORDER BY datetime(created_at)
`); `);
const insertChatSessionStmt = db.prepare( const insertChatSessionStmt = db.prepare(
"INSERT INTO chat_sessions (username, title) VALUES (?, ?)" "INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id) VALUES (?, ?, ?, ?)"
); );
const listChatSessionsStmt = db.prepare(` const listChatSessionsStmt = db.prepare(`
SELECT id, title, created_at, updated_at SELECT cs.id,
FROM chat_sessions CASE
WHERE username = ? WHEN cs.assistant_persona_id IS NOT NULL THEN COALESCE(p.name, cs.title)
ORDER BY datetime(updated_at) DESC ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name,
cs.created_at,
cs.updated_at
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
WHERE cs.username = ?
ORDER BY datetime(cs.updated_at) DESC
`); `);
const countChatSessionsStmt = db.prepare(` const countChatSessionsStmt = db.prepare(`
@@ -326,9 +359,47 @@ const countChatSessionsStmt = db.prepare(`
WHERE username = ? WHERE username = ?
`); `);
const getChatSessionStmt = db.prepare( const getChatSessionStmt = db.prepare(`
"SELECT id, title FROM chat_sessions WHERE id = ? AND username = ?" SELECT cs.id,
); CASE
WHEN cs.assistant_persona_id IS NOT NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
WHERE cs.id = ?
AND cs.username = ?
`);
const getChatSessionByParticipantsStmt = db.prepare(`
SELECT cs.id,
CASE
WHEN cs.assistant_persona_id IS NOT NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
WHERE cs.username = ?
AND cs.assistant_persona_id = ?
AND (
(cs.user_persona_id IS NULL AND ? IS NULL)
OR cs.user_persona_id = ?
)
ORDER BY datetime(cs.updated_at) DESC
LIMIT 1
`);
const updateChatSessionTitleStmt = db.prepare( const updateChatSessionTitleStmt = db.prepare(
"UPDATE chat_sessions SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?" "UPDATE chat_sessions SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"
@@ -342,6 +413,22 @@ const deleteChatSessionStmt = db.prepare(
"DELETE FROM chat_sessions WHERE id = ? AND username = ?" "DELETE FROM chat_sessions WHERE id = ? AND username = ?"
); );
const clearAssistantPersonaFromChatsStmt = db.prepare(`
UPDATE chat_sessions
SET assistant_persona_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?
AND assistant_persona_id = ?
`);
const clearUserPersonaFromChatsStmt = db.prepare(`
UPDATE chat_sessions
SET user_persona_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?
AND user_persona_id = ?
`);
const insertChatMessageStmt = db.prepare( const insertChatMessageStmt = db.prepare(
"INSERT INTO chat_messages (chat_id, role, content) VALUES (?, ?, ?)" "INSERT INTO chat_messages (chat_id, role, content) VALUES (?, ?, ?)"
); );
@@ -472,6 +559,22 @@ const getActiveUserPersonaStmt = db.prepare(`
WHERE us.username = ? WHERE us.username = ?
`); `);
const getAssistantPersonaForChatStmt = db.prepare(`
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
FROM chat_sessions cs
JOIN personas p ON p.id = cs.assistant_persona_id
WHERE cs.id = ?
AND cs.username = ?
`);
const getUserPersonaForChatStmt = db.prepare(`
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
FROM chat_sessions cs
JOIN personas p ON p.id = cs.user_persona_id
WHERE cs.id = ?
AND cs.username = ?
`);
const listMarketPersonasStmt = db.prepare(` const listMarketPersonasStmt = db.prepare(`
SELECT id, SELECT id,
persona_id, persona_id,
@@ -630,7 +733,7 @@ if (legacyChatsExist && migrationNeeded.count === 0) {
"SELECT DISTINCT username FROM chats" "SELECT DISTINCT username FROM chats"
).all(); ).all();
legacyUsers.forEach(({username}) => { legacyUsers.forEach(({username}) => {
const sessionId = insertChatSessionStmt.run(username, "Imported chat").lastInsertRowid; const sessionId = insertChatSessionStmt.run(username, "Imported chat", null, null).lastInsertRowid;
const legacyMessages = getLegacyChatsStmt.all(username); const legacyMessages = getLegacyChatsStmt.all(username);
legacyMessages.forEach(msg => { legacyMessages.forEach(msg => {
insertChatMessageStmt.run(sessionId, msg.role, msg.content); insertChatMessageStmt.run(sessionId, msg.role, msg.content);
@@ -681,6 +784,16 @@ if (!hasMarketExampleDialogues) {
} }
db.prepare("UPDATE persona_market SET usage_count = 0 WHERE usage_count IS NULL").run(); db.prepare("UPDATE persona_market SET usage_count = 0 WHERE usage_count IS NULL").run();
const chatSessionColumns = db.prepare("PRAGMA table_info(chat_sessions)").all();
const hasAssistantPersonaLink = chatSessionColumns.some(column => column.name === "assistant_persona_id");
if (!hasAssistantPersonaLink) {
db.prepare("ALTER TABLE chat_sessions ADD COLUMN assistant_persona_id INTEGER").run();
}
const hasChatUserPersonaLink = chatSessionColumns.some(column => column.name === "user_persona_id");
if (!hasChatUserPersonaLink) {
db.prepare("ALTER TABLE chat_sessions ADD COLUMN user_persona_id INTEGER").run();
}
const FileStore = FileStoreFactory(session); const FileStore = FileStoreFactory(session);
app.use(bodyParser.json()); app.use(bodyParser.json());
app.use(express.static("public")); app.use(express.static("public"));
@@ -897,6 +1010,77 @@ const buildUserPersonaPrompt = (persona) => {
return lines.join("\n"); return lines.join("\n");
}; };
const createPersonaChat = (user, persona, userPersonaId = null) => {
const chatId = insertChatSessionStmt.run(user, persona.name, persona.id, userPersonaId).lastInsertRowid;
return getChatSessionStmt.get(chatId, user);
};
const getOrCreatePersonaChat = (user, persona, userPersonaId = null) => {
const existing = getChatSessionByParticipantsStmt.get(user, persona.id, userPersonaId, userPersonaId);
if (existing) return existing;
const sessionCount = countChatSessionsStmt.get(user).count;
if (sessionCount >= 10) {
throw new Error("CHAT_LIMIT_REACHED");
}
return createPersonaChat(user, persona, userPersonaId);
};
const ensureModelState = (model) => {
if (!modelState.has(model)) {
modelState.set(model, {activeRequests: 0, timer: null});
}
return modelState.get(model);
};
const generateModelReply = async (selectedModel, messagesPayload) => {
const state = ensureModelState(selectedModel);
state.activeRequests++;
try {
const response = await fetch("https://ai.krishd.ch/api/chat", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
model: selectedModel,
messages: messagesPayload,
stream: true
})
});
if (!response.ok) {
throw new Error(`AI HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let fullReply = "";
while (true) {
const {value, done} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const data = JSON.parse(trimmed);
if (data.message?.content) fullReply += data.message.content;
}
}
if (buffer.trim()) {
const data = JSON.parse(buffer.trim());
if (data.message?.content) fullReply += data.message.content;
}
return fullReply.trim();
} finally {
state.activeRequests = Math.max(0, state.activeRequests - 1);
scheduleModelUnload(selectedModel);
}
};
app.get("/models", async (req, res) => { app.get("/models", async (req, res) => {
try { try {
if (modelsCache && (Date.now() - modelsCacheAt) < MODELS_TTL_MS) { if (modelsCache && (Date.now() - modelsCacheAt) < MODELS_TTL_MS) {
@@ -924,14 +1108,33 @@ app.get("/chats", requireLogin, (req, res) => {
app.post("/chats", requireLogin, (req, res) => { app.post("/chats", requireLogin, (req, res) => {
const user = req.session.user; const user = req.session.user;
const assistantPersonaId = req.body?.assistantPersonaId ? Number(req.body.assistantPersonaId) : null;
if (assistantPersonaId) {
const persona = getPersonaStmt.get(assistantPersonaId, user);
if (!persona || persona.persona_type !== "assistant") {
return res.status(404).json({error: "AI Character not found"});
}
const existingChat = getChatSessionByParticipantsStmt.get(user, assistantPersonaId, null, null);
try {
const chat = getOrCreatePersonaChat(user, persona, null);
return res.json({chat, existing: Boolean(existingChat)});
} catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: "Maximum of 10 chats reached. Please delete an old chat to create a new one."});
}
throw error;
}
}
const sessionCount = countChatSessionsStmt.get(user).count; const sessionCount = countChatSessionsStmt.get(user).count;
if (sessionCount >= 10) { if (sessionCount >= 10) {
return res.status(400).json({error: "Maximum of 10 chats reached. Please delete an old chat to create a new one."}); return res.status(400).json({error: "Maximum of 10 chats reached. Please delete an old chat to create a new one."});
} }
const title = (req.body?.title || "New chat").trim() || "New chat"; const title = (req.body?.title || "New chat").trim() || "New chat";
const chatId = insertChatSessionStmt.run(user, title).lastInsertRowid; const chatId = insertChatSessionStmt.run(user, title, null, null).lastInsertRowid;
res.json({chat: {id: chatId, title}}); const chat = getChatSessionStmt.get(chatId, user);
res.json({chat, existing: false});
}); });
app.put("/chats/:id", requireLogin, (req, res) => { app.put("/chats/:id", requireLogin, (req, res) => {
@@ -966,7 +1169,7 @@ app.get("/chats/:id/messages", requireLogin, (req, res) => {
return res.status(404).json({error: "Chat not found"}); return res.status(404).json({error: "Chat not found"});
} }
const messages = getChatMessagesStmt.all(chatId); const messages = getChatMessagesStmt.all(chatId);
res.json({messages}); res.json({messages, chat: session});
}); });
app.post("/chats/:id/clear", requireLogin, (req, res) => { app.post("/chats/:id/clear", requireLogin, (req, res) => {
@@ -990,7 +1193,6 @@ app.get("/personas", requireLogin, (req, res) => {
res.json({ res.json({
assistantPersonas, assistantPersonas,
userPersonas, userPersonas,
activePersonaId: activePersonas.active_persona_id ?? null,
activeUserPersonaId: activePersonas.active_user_persona_id ?? null, activeUserPersonaId: activePersonas.active_user_persona_id ?? null,
publishedPersonaIds publishedPersonaIds
}); });
@@ -1054,25 +1256,39 @@ app.delete("/personas/:id", requireLogin, (req, res) => {
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 (activePersonas.active_persona_id === personaId) {
setActivePersonaStmt.run(user, null);
}
if (activePersonas.active_user_persona_id === personaId) { if (activePersonas.active_user_persona_id === personaId) {
setActiveUserPersonaStmt.run(user, null); setActiveUserPersonaStmt.run(user, null);
} }
clearAssistantPersonaFromChatsStmt.run(user, personaId);
clearUserPersonaFromChatsStmt.run(user, personaId);
deleteMarketPersonaByPersonaIdStmt.run(personaId, user); deleteMarketPersonaByPersonaIdStmt.run(personaId, user);
res.json({message: "Persona deleted"}); res.json({message: "Persona deleted"});
}); });
app.post("/personas/:id/equip", requireLogin, (req, res) => { app.post("/personas/:id/chat", 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 userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null;
const persona = getPersonaStmt.get(personaId, user); const persona = getPersonaStmt.get(personaId, user);
if (!persona || persona.persona_type !== "assistant") { 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); if (userPersonaId) {
res.json({activePersonaId: personaId}); const userPersona = getPersonaStmt.get(userPersonaId, user);
if (!userPersona || userPersona.persona_type !== "user") {
return res.status(404).json({error: "User persona not found"});
}
}
let chat;
try {
chat = getOrCreatePersonaChat(user, persona, userPersonaId);
} catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: "Maximum of 10 chats reached. Please delete an old chat to create a new one."});
}
throw error;
}
res.json({chat});
}); });
app.post("/personas/:id/equip-user", requireLogin, (req, res) => { app.post("/personas/:id/equip-user", requireLogin, (req, res) => {
@@ -1086,12 +1302,6 @@ app.post("/personas/:id/equip-user", requireLogin, (req, res) => {
res.json({activeUserPersonaId: personaId}); res.json({activeUserPersonaId: personaId});
}); });
app.post("/personas/clear", requireLogin, (req, res) => {
const user = req.session.user;
setActivePersonaStmt.run(user, null);
res.json({activePersonaId: null});
});
app.post("/personas/user/clear", requireLogin, (req, res) => { app.post("/personas/user/clear", requireLogin, (req, res) => {
const user = req.session.user; const user = req.session.user;
setActiveUserPersonaStmt.run(user, null); setActiveUserPersonaStmt.run(user, null);
@@ -1192,19 +1402,140 @@ app.post("/personas/market/:id/collect", requireLogin, (req, res) => {
marketPersona.creator_username marketPersona.creator_username
).lastInsertRowid; ).lastInsertRowid;
incrementMarketUsageCountStmt.run(marketId); incrementMarketUsageCountStmt.run(marketId);
let activePersonaId = null;
let activeUserPersonaId = null; let activeUserPersonaId = null;
if (req.body?.equip) { if (req.body?.equip) {
if (marketPersona.persona_type === "user") { if (marketPersona.persona_type === "user") {
setActiveUserPersonaStmt.run(user, personaId); setActiveUserPersonaStmt.run(user, personaId);
activeUserPersonaId = personaId; activeUserPersonaId = personaId;
} else {
setActivePersonaStmt.run(user, personaId);
activePersonaId = personaId;
} }
} }
const persona = getPersonaStmt.get(personaId, user); const persona = getPersonaStmt.get(personaId, user);
res.json({persona, activePersonaId, activeUserPersonaId}); res.json({persona, activeUserPersonaId});
});
app.post("/personas/market/:id/chat", requireLogin, (req, res) => {
const user = req.session.user;
const marketId = Number(req.params.id);
const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null;
const marketPersona = getMarketPersonaStmt.get(marketId);
if (!marketPersona || marketPersona.persona_type !== "assistant") {
return res.status(404).json({error: "Market AI Character not found"});
}
let userPersona = null;
if (userPersonaId) {
userPersona = getPersonaStmt.get(userPersonaId, user);
if (!userPersona || userPersona.persona_type !== "user") {
return res.status(404).json({error: "User persona not found"});
}
}
let personaId = null;
const existingPersona = getPersonaBySourceMarketStmt.get(user, marketId);
if (existingPersona) {
personaId = existingPersona.id;
} else {
personaId = insertMarketPersonaStmt.run(
user,
marketPersona.name,
marketPersona.pronouns,
marketPersona.appearance,
marketPersona.background,
marketPersona.details,
marketPersona.example_dialogues,
marketPersona.persona_type,
marketId,
marketPersona.creator_username
).lastInsertRowid;
incrementMarketUsageCountStmt.run(marketId);
}
const persona = getPersonaStmt.get(personaId, user);
let chat;
try {
chat = getOrCreatePersonaChat(user, persona, userPersonaId);
} catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: "Maximum of 10 chats reached. Please delete an old chat to create a new one."});
}
throw error;
}
const existingMessages = getChatMessagesStmt.all(chat.id);
if (existingMessages.length > 0) {
return res.json({persona, chat, existing: true, generatedInitialMessage: false});
}
Promise.resolve(generateModelReply("mistral:latest", [
{role: "system", content: buildPersonaPrompt(persona)},
...(userPersona ? [{role: "system", content: buildUserPersonaPrompt(userPersona)}] : []),
{role: "user", content: "Open this roleplay with the very first in-character message. Set the scene naturally, stay in persona, and do not ask for permission to begin."}
])).then((opener) => {
insertChatMessageStmt.run(chat.id, "bot", opener);
touchChatSessionStmt.run(chat.id, user);
res.json({persona, chat, existing: false, generatedInitialMessage: true, opener});
}).catch(() => {
res.json({persona, chat, existing: false, generatedInitialMessage: false});
});
});
app.post("/roleplays/start", requireLogin, async (req, res) => {
const user = req.session.user;
const selectedModel = req.body?.model || "mistral:latest";
const assistantPersonaId = Number(req.body?.assistantPersonaId);
const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null;
if (!assistantPersonaId) {
return res.status(400).json({error: "assistantPersonaId is required"});
}
const assistantPersona = getPersonaStmt.get(assistantPersonaId, user);
if (!assistantPersona || assistantPersona.persona_type !== "assistant") {
return res.status(404).json({error: "Character not found"});
}
let userPersona = null;
if (userPersonaId) {
userPersona = getPersonaStmt.get(userPersonaId, user);
if (!userPersona || userPersona.persona_type !== "user") {
return res.status(404).json({error: "User persona not found"});
}
}
let chat;
try {
chat = getOrCreatePersonaChat(user, assistantPersona, userPersonaId);
} catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: "Maximum of 10 chats reached. Please delete an old chat to create a new one."});
}
throw error;
}
const existingMessages = getChatMessagesStmt.all(chat.id);
if (existingMessages.length > 0) {
return res.json({chat, existing: true, generatedInitialMessage: false});
}
const messagesPayload = [
{role: "system", content: buildPersonaPrompt(assistantPersona)}
];
if (userPersona) {
messagesPayload.push({role: "system", content: buildUserPersonaPrompt(userPersona)});
}
messagesPayload.push({
role: "user",
content: "Open this roleplay with the very first in-character message. Set the scene naturally, stay in persona, and do not ask for permission to begin."
});
try {
const opener = await generateModelReply(selectedModel, messagesPayload);
if (!opener) {
return res.status(500).json({error: "Failed to generate the character opener"});
}
insertChatMessageStmt.run(chat.id, "bot", opener);
touchChatSessionStmt.run(chat.id, user);
return res.json({chat, existing: false, generatedInitialMessage: true, opener});
} catch {
return res.status(500).json({error: "Failed to generate the character opener"});
}
}); });
app.post("/chat", requireLogin, async (req, res) => { app.post("/chat", requireLogin, async (req, res) => {
@@ -1222,15 +1553,9 @@ app.post("/chat", requireLogin, async (req, res) => {
return res.status(404).json({error: "Chat not found"}); return res.status(404).json({error: "Chat not found"});
} }
if (!modelState.has(selectedModel)) {
modelState.set(selectedModel, {activeRequests: 0, timer: null});
}
const state = modelState.get(selectedModel);
state.activeRequests++;
try { try {
const activePersona = getActivePersonaStmt.get(user); const activePersona = getAssistantPersonaForChatStmt.get(targetChatId, user);
const activeUserPersona = getActiveUserPersonaStmt.get(user); const activeUserPersona = getUserPersonaForChatStmt.get(targetChatId, user) || 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});
@@ -1255,41 +1580,7 @@ app.post("/chat", requireLogin, async (req, res) => {
content: m.content content: m.content
})) }))
); );
const fullReply = await generateModelReply(selectedModel, messagesPayload);
const response = await fetch("https://ai.krishd.ch/api/chat", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
model: selectedModel,
messages: messagesPayload,
stream: true
})
});
if (!response.ok) {
throw new Error(`AI HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let fullReply = "";
while (true) {
const {value, done} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
let lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
const json = JSON.parse(line);
if (json.done) {
break;
}
if (json.message?.content) {
fullReply += json.message.content;
}
}
}
insertChatMessageStmt.run(targetChatId, "bot", fullReply); insertChatMessageStmt.run(targetChatId, "bot", fullReply);
touchChatSessionStmt.run(targetChatId, user); touchChatSessionStmt.run(targetChatId, user);
console.log("Reply length:", fullReply.length); console.log("Reply length:", fullReply.length);
@@ -1297,12 +1588,6 @@ app.post("/chat", requireLogin, async (req, res) => {
} catch (err) { } catch (err) {
console.error("AI chat error:", err); console.error("AI chat error:", err);
res.status(500).json({error: "AI request failed"}); res.status(500).json({error: "AI request failed"});
} finally {
state.activeRequests--;
if (state.activeRequests === 0) {
scheduleModelUnload(selectedModel);
}
} }
}); });