try to make app more intuitive

This commit is contained in:
2026-04-09 22:53:19 +02:00
parent 602e29e54b
commit 057f59ceef
6 changed files with 344 additions and 39 deletions
+9 -1
View File
@@ -113,6 +113,7 @@
<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>
<span id="chatScenePill" class="context-pill hidden">Scene: none</span>
</div>
</div>
<div class="chat-toolbar">
@@ -179,7 +180,9 @@
<div class="input-shell input-shell-focus">
<div class="input-meta">
<p class="composer-hint">Enter sends. Shift+Enter adds a new line.</p>
<p id="appNotice" class="status inline-status hidden" aria-live="polite"></p>
</div>
<div id="quickPrompts" class="quick-prompts" aria-label="Suggested starters"></div>
<div class="input-container">
<textarea id="msgInput" placeholder="Type your message..."></textarea>
<button id="send" type="button" aria-label="Send message"></button>
@@ -264,7 +267,7 @@
</div>
<p id="roleplayStarterNotice" class="status hidden"></p>
<div class="roleplay-starter-actions">
<button id="roleplayNewPersona" class="secondary-action" type="button">New persona</button>
<button id="roleplayNewPersona" class="secondary-action" type="button">Create AI Character</button>
<a href="/settings" class="secondary-action">Settings</a>
</div>
<div class="persona-form-grid">
@@ -276,7 +279,12 @@
<span>User persona</span>
<select id="roleplayUserPersonaSelect"></select>
</label>
<label class="field field-wide">
<span>Scenario seed</span>
<textarea id="roleplayScenarioInput" rows="3" placeholder="Optional. Describe the kind of opening scene you want, such as a setting, conflict, relationship dynamic, or mood."></textarea>
</label>
</div>
<div id="roleplayStarterSuggestions" class="quick-prompts" aria-label="Scenario suggestions"></div>
<div class="popup-actions">
<button id="roleplayStarterCancel" type="button" class="secondary-action">Cancel</button>
<button id="roleplayStarterConfirm" type="button">Start roleplay</button>
+133 -9
View File
@@ -11,7 +11,7 @@ const loginSubmit = $("loginSubmit"), registerSubmit = $("registerSubmit");
const chatList = $("chatList"), chatSearchInput = $("chatSearch"), newChatBtn = $("newChat");
const renameChatBtn = $("renameChat"), clearChatBtn = $("clearChat"), exportChatBtn = $("exportChat");
const activeChatTitle = $("activeChatTitle"), sessionUser = $("sessionUser");
const chatModePill = $("chatModePill"), chatCharacterPill = $("chatCharacterPill"), chatUserPersonaPill = $("chatUserPersonaPill");
const chatModePill = $("chatModePill"), chatCharacterPill = $("chatCharacterPill"), chatUserPersonaPill = $("chatUserPersonaPill"), chatScenePill = $("chatScenePill");
const personaList = $("personaList"), userPersonaList = $("userPersonaList");
const personaForm = $("personaForm"), personaFormTitle = $("personaFormTitle"), personaTypeSelect = $("personaType");
const personaNameInput = $("personaName"), personaPronounsInput = $("personaPronouns"), personaAppearanceInput = $("personaAppearance");
@@ -23,9 +23,11 @@ const activePersonaStatus = $("activePersonaStatus"), activeUserPersonaStatus =
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 roleplayScenarioInput = $("roleplayScenarioInput"), roleplayStarterSuggestions = $("roleplayStarterSuggestions");
const popupModal = $("popupModal"), popupCloseBtn = $("popupClose"), popupCancelBtn = $("popupCancel"), popupConfirmBtn = $("popupConfirm");
const popupTitle = $("popupTitle"), popupEyebrow = $("popupEyebrow"), popupDescription = $("popupDescription"), popupField = $("popupField");
const popupInputLabel = $("popupInputLabel"), popupInput = $("popupInput");
const appNotice = $("appNotice"), quickPrompts = $("quickPrompts");
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 requestedChatId = Number(new URLSearchParams(window.location.search).get("chat")) || null;
@@ -35,6 +37,7 @@ let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersona
let activeUserPersonaId = null, currentSummary = null, publishedPersonaIds = new Set();
let popupResolver = null, popupMode = null, popupLastFocus = null;
let roleplayPresetCharacterId = null;
let noticeTimer = null;
const themes = {
"fakegpt": {name: "FakeGPT", short: "FG"},
@@ -64,7 +67,27 @@ const put = (url, data) => request(url, data, "PUT");
const del = (url) => request(url, null, "DELETE");
function setAuthMessage(message, state = "") { authMsg.textContent = message; authMsg.className = state ? `status ${state}` : "status"; }
function setNotice(message, state = "") { return {message, state}; }
function setNotice(message = "", state = "") {
if (!appNotice) return {message, state};
if (noticeTimer) {
clearTimeout(noticeTimer);
noticeTimer = null;
}
if (!message) {
appNotice.textContent = "";
appNotice.className = "status inline-status hidden";
return {message, state};
}
appNotice.textContent = message;
appNotice.className = state ? `status inline-status ${state}` : "status inline-status";
noticeTimer = window.setTimeout(() => {
if (appNotice.textContent === message) {
appNotice.textContent = "";
appNotice.className = "status inline-status hidden";
}
}, state === "error" ? 6000 : 3200);
return {message, state};
}
function setPersonaFormNotice(message = "", state = "") {
if (!personaFormNotice) return;
if (!message) {
@@ -85,6 +108,82 @@ function setMessageContent(el, content) {
}
}
function getChatById(id) { return chatSessions.find((chat) => chat.id === id); }
function summarizeText(value, limit = 96) {
const normalized = String(value || "").replace(/\s+/g, " ").trim();
if (!normalized) return "";
return normalized.length > limit ? `${normalized.slice(0, limit - 3).trimEnd()}...` : normalized;
}
function getPersonaName(id, items) {
return items.find((persona) => persona.id === id)?.name || "";
}
function getRoleplayStarterSuggestions() {
const assistantPersonaId = Number(roleplayCharacterSelect?.value);
const userPersonaId = roleplayUserPersonaSelect?.value ? Number(roleplayUserPersonaSelect.value) : null;
const assistantName = getPersonaName(assistantPersonaId, assistantPersonas) || "the character";
const userName = userPersonaId ? getPersonaName(userPersonaId, userPersonas) : "me";
return [
`${assistantName} unexpectedly crosses paths with ${userName} in a place that matters to them both.`,
`${assistantName} already knows something important about ${userName} and confronts them with it.`,
`${assistantName} and ${userName} are forced to cooperate while something goes wrong around them.`,
`${assistantName} meets ${userName} in a quiet, intimate scene with unresolved tension.`,
`${assistantName} draws ${userName} into a high-stakes problem that starts immediately.`
];
}
function updateRoleplaySuggestions() {
if (!roleplayStarterSuggestions) return;
roleplayStarterSuggestions.innerHTML = "";
getRoleplayStarterSuggestions().forEach((suggestion) => {
const button = document.createElement("button");
button.type = "button";
button.className = "prompt-chip";
button.textContent = suggestion;
button.addEventListener("click", () => {
if (roleplayScenarioInput) {
roleplayScenarioInput.value = suggestion;
roleplayScenarioInput.focus();
}
});
roleplayStarterSuggestions.appendChild(button);
});
}
function getQuickPromptsForChat(chat) {
if (chat?.assistant_persona_id) {
const characterName = chat.assistant_persona_name || chat.title || "the character";
const userName = chat.user_persona_name || "me";
return [
`Stay in character and describe what ${characterName} notices about ${userName} right now.`,
`Push the scene forward with a new complication.`,
`Respond with sharper tension and more specific detail.`,
`Let ${characterName} reveal what they actually want from me.`
];
}
return [];
}
function renderQuickPrompts() {
if (!quickPrompts) return;
const activeChat = getChatById(activeChatId);
const prompts = getQuickPromptsForChat(activeChat);
quickPrompts.innerHTML = "";
quickPrompts.classList.toggle("hidden", prompts.length === 0);
prompts.forEach((prompt) => {
const button = document.createElement("button");
button.type = "button";
button.className = "prompt-chip";
button.textContent = prompt;
button.addEventListener("click", () => {
msgInput.value = prompt;
msgInput.dispatchEvent(new Event("input"));
msgInput.focus();
});
quickPrompts.appendChild(button);
});
}
function updateComposerPlaceholder() {
const activeChat = getChatById(activeChatId);
msgInput.placeholder = activeChat?.assistant_persona_id
? "Reply in the scene..."
: "Type your message...";
}
function addMessage(content, isUser = false, isLoading = false) {
const msgDiv = document.createElement("div");
@@ -105,7 +204,8 @@ function addMessage(content, isUser = false, isLoading = false) {
} else {
const header = document.createElement("div"), body = document.createElement("div");
header.className = "msg-header"; body.className = "msg-content";
header.textContent = isUser ? "You" : "Assistant";
const activeChat = getChatById(activeChatId);
header.textContent = isUser ? "You" : (activeChat?.assistant_persona_name || "Assistant");
setMessageContent(body, content);
msgDiv.append(header, body);
}
@@ -119,6 +219,8 @@ function renderMessages() {
currentMessages.forEach((msg) => addMessage(msg.content, msg.role === "user"));
updateChatParticipants();
updateChatActionState();
renderQuickPrompts();
updateComposerPlaceholder();
}
function updateWorkspaceCopy() {
@@ -130,6 +232,8 @@ function updateWorkspaceCopy() {
sessionUser.textContent = currentUsername || "-";
}
updateChatParticipants();
renderQuickPrompts();
updateComposerPlaceholder();
}
function setRoleplayStarterNotice(message = "", state = "") {
@@ -145,11 +249,12 @@ function setRoleplayStarterNotice(message = "", state = "") {
function updateChatParticipants() {
const chat = getChatById(activeChatId);
if (!chatModePill || !chatCharacterPill || !chatUserPersonaPill) return;
if (!chatModePill || !chatCharacterPill || !chatUserPersonaPill || !chatScenePill) return;
if (!chat) {
chatModePill.textContent = "No chat selected";
chatCharacterPill.classList.add("hidden");
chatUserPersonaPill.classList.add("hidden");
chatScenePill.classList.add("hidden");
return;
}
if (chat.assistant_persona_id) {
@@ -163,11 +268,18 @@ function updateChatParticipants() {
chatUserPersonaPill.textContent = "You as: yourself";
chatUserPersonaPill.classList.remove("hidden");
}
if (chat.scenario_summary) {
chatScenePill.textContent = `Scene: ${summarizeText(chat.scenario_summary, 112)}`;
chatScenePill.classList.remove("hidden");
} else {
chatScenePill.classList.add("hidden");
}
return;
}
chatModePill.textContent = "Assistant mode";
chatCharacterPill.classList.add("hidden");
chatUserPersonaPill.classList.add("hidden");
chatScenePill.classList.add("hidden");
}
function updateContextRail() {
@@ -292,8 +404,8 @@ function updateActivePersonaStatus(items, activeId, statusEl, emptyText, prefix)
function setActivePersonaStatus() {
if (activePersonaStatus) {
activePersonaStatus.textContent = assistantPersonas.length
? "Pick a character, choose a user persona, and start the roleplay."
: "No characters yet. Create one to start a roleplay.";
? "Choose an AI Character, pair it with a user persona if needed, and start the scene."
: "No AI 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;
@@ -347,12 +459,14 @@ function populateRoleplayStarter() {
option.selected = persona.id === activeUserPersonaId;
roleplayUserPersonaSelect.appendChild(option);
});
updateRoleplaySuggestions();
}
function openRoleplayStarter(characterId = null) {
closePersonaPopover();
closeModelPopover();
roleplayPresetCharacterId = characterId;
populateRoleplayStarter();
if (roleplayScenarioInput) roleplayScenarioInput.value = "";
setRoleplayStarterNotice("");
roleplayStarterModal.classList.remove("hidden");
if (roleplayCharacterSelect.options.length) roleplayCharacterSelect.focus();
@@ -362,6 +476,7 @@ function closeRoleplayStarter() {
roleplayStarterModal.classList.add("hidden");
roleplayPresetCharacterId = null;
roleplayStarterConfirm.disabled = false;
if (roleplayScenarioInput) roleplayScenarioInput.value = "";
setRoleplayStarterNotice("");
}
function closeModelPopover() {
@@ -539,7 +654,12 @@ async function unpublishPersona(id) { const res = await post(`/personas/${id}/un
function setLoadingState(loading) {
isProcessing = loading; sendBtn.disabled = loading; msgInput.disabled = loading;
sendBtn.classList.toggle("loading", loading); msgInput.placeholder = loading ? "Processing response..." : "Type your message...";
sendBtn.classList.toggle("loading", loading);
if (loading) {
msgInput.placeholder = "Processing response...";
} else {
updateComposerPlaceholder();
}
}
function showAuthScreen(target) {
@@ -578,14 +698,16 @@ async function createNewChat() {
async function startRoleplay() {
const assistantPersonaId = Number(roleplayCharacterSelect.value);
const userPersonaId = roleplayUserPersonaSelect.value ? Number(roleplayUserPersonaSelect.value) : null;
const scenarioPrompt = roleplayScenarioInput?.value.trim() || "";
if (!assistantPersonaId) {
return setRoleplayStarterNotice("Choose a character to start the roleplay.", "error");
}
roleplayStarterConfirm.disabled = true;
setRoleplayStarterNotice("Starting roleplay...");
setRoleplayStarterNotice("Building the opening scene...");
const res = await post("/roleplays/start", {
assistantPersonaId,
userPersonaId,
scenarioPrompt,
model: modelSelect.value
});
roleplayStarterConfirm.disabled = false;
@@ -595,7 +717,7 @@ async function startRoleplay() {
closeRoleplayStarter();
await loadChatSessions();
await setActiveChat(res.chat.id);
setNotice(res.generatedInitialMessage ? "Roleplay started." : "Roleplay reopened.", "success");
setNotice(res.generatedInitialMessage ? "Roleplay started with a fresh scene." : "Roleplay reopened.", "success");
}
async function renameChat(id) {
@@ -778,6 +900,8 @@ personaModal.addEventListener("click", (event) => { if (event.target === persona
roleplayStarterClose.addEventListener("click", closeRoleplayStarter);
roleplayStarterCancel.addEventListener("click", closeRoleplayStarter);
roleplayStarterConfirm.addEventListener("click", () => { void startRoleplay(); });
roleplayCharacterSelect.addEventListener("change", updateRoleplaySuggestions);
roleplayUserPersonaSelect.addEventListener("change", updateRoleplaySuggestions);
roleplayStarterModal.addEventListener("click", (event) => { if (event.target === roleplayStarterModal) closeRoleplayStarter(); });
popupConfirmBtn.addEventListener("click", () => {
if (popupMode === "prompt") {
+11 -6
View File
@@ -25,6 +25,11 @@
<p id="settingsNotice" class="status">Loading settings...</p>
<section class="settings-quick-actions">
<button id="quickCreateAiCharacter" type="button" class="primary-action">Create AI Character</button>
<button id="quickCreateUserPersona" type="button" class="secondary-action">Create User Persona</button>
</section>
<section class="settings-layout">
<aside class="settings-nav" aria-label="Settings sections">
<button class="settings-nav-item active" type="button" data-settings-view="personal">Personal</button>
@@ -88,11 +93,11 @@
<div class="settings-panel">
<div class="settings-section-header">
<div>
<p class="eyebrow">Manage</p>
<p class="eyebrow">Create & manage</p>
<h2>My AI Characters</h2>
<p class="subtitle">Create, edit, publish, and delete your AI Characters.</p>
<p class="subtitle">Build characters for roleplay chats and publish them to the market when they are ready.</p>
</div>
<button id="createAiCharacter" type="button" class="secondary-action">Create persona</button>
<button id="createAiCharacter" type="button" class="secondary-action">Create AI Character</button>
</div>
<div id="settingsAiCharacterList" class="persona-list"></div>
</div>
@@ -102,11 +107,11 @@
<div class="settings-panel">
<div class="settings-section-header">
<div>
<p class="eyebrow">Manage</p>
<p class="eyebrow">Create & manage</p>
<h2>My Personas</h2>
<p class="subtitle">Create and manage the user personas you can speak as in roleplay chats.</p>
<p class="subtitle">Create the personas you want to speak as, then set one as default for faster roleplay setup.</p>
</div>
<button id="createUserPersona" type="button" class="secondary-action">Create persona</button>
<button id="createUserPersona" type="button" class="secondary-action">Create user persona</button>
</div>
<div id="settingsUserPersonaList" class="persona-list"></div>
</div>
+25 -8
View File
@@ -13,6 +13,8 @@ const settingsNavItems = document.querySelectorAll("[data-settings-view]");
const settingsPanels = document.querySelectorAll("[data-settings-panel]");
const createAiCharacterBtn = $("createAiCharacter");
const createUserPersonaBtn = $("createUserPersona");
const quickCreateAiCharacterBtn = $("quickCreateAiCharacter");
const quickCreateUserPersonaBtn = $("quickCreateUserPersona");
const settingsAiCharacterList = $("settingsAiCharacterList");
const settingsUserPersonaList = $("settingsUserPersonaList");
const settingsPersonaModal = $("settingsPersonaModal");
@@ -101,8 +103,8 @@ 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.";
? "Create the character you want to roleplay with."
: "Create the persona you want to speak as in roleplay.";
$("settingsPersonaSave").textContent = isAssistant ? "Save AI Character" : "Save persona";
}
@@ -134,11 +136,17 @@ function closePersonaModal() {
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.";
const empty = document.createElement("div");
const message = document.createElement("p");
const button = document.createElement("button");
empty.className = "persona-empty-state";
message.className = "status persona-status";
message.textContent = type === "assistant" ? "No AI Characters yet." : "No user personas yet.";
button.type = "button";
button.className = "secondary-action";
button.textContent = type === "assistant" ? "Create AI Character" : "Create user persona";
button.addEventListener("click", () => openPersonaModal(null, type));
empty.append(message, button);
listElement.appendChild(empty);
return;
}
@@ -213,6 +221,7 @@ async function loadPersonas() {
}
async function savePersona() {
const wasEditing = Boolean(editingPersonaId);
const payload = {
personaType: settingsPersonaType.value,
name: settingsPersonaName.value.trim(),
@@ -240,7 +249,7 @@ async function savePersona() {
await loadPersonas();
closePersonaModal();
setSettingsView(payload.personaType === "assistant" ? "my-ai-characters" : "my-personas");
setNotice(editingPersonaId ? "Persona updated." : "Persona created.", "success");
setNotice(wasEditing ? "Persona updated." : "Persona created.", "success");
}
async function deletePersona(id) {
@@ -322,6 +331,14 @@ settingsThemeSelect.addEventListener("change", (event) => {
});
settingsNavItems.forEach((item) => item.addEventListener("click", () => setSettingsView(item.dataset.settingsView)));
quickCreateAiCharacterBtn.addEventListener("click", () => {
setSettingsView("my-ai-characters");
openPersonaModal(null, "assistant");
});
quickCreateUserPersonaBtn.addEventListener("click", () => {
setSettingsView("my-personas");
openPersonaModal(null, "user");
});
createAiCharacterBtn.addEventListener("click", () => openPersonaModal(null, "assistant"));
createUserPersonaBtn.addEventListener("click", () => openPersonaModal(null, "user"));
settingsPersonaClose.addEventListener("click", closePersonaModal);
+27
View File
@@ -530,6 +530,7 @@ select:focus {
.inline-status {
min-height: auto;
padding: 10px 12px;
flex: 1 1 260px;
}
.consent-container {
@@ -1043,6 +1044,9 @@ select:focus {
}
.prompt-chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 9px 12px;
border-radius: 999px;
background: rgba(255, 251, 245, 0.65);
@@ -1453,6 +1457,8 @@ select:focus {
border-radius: 16px;
background: rgba(24, 32, 31, 0.04);
border: 1px solid rgba(24, 32, 31, 0.08);
display: grid;
gap: 14px;
}
.persona-form > .status {
@@ -1594,6 +1600,17 @@ select:focus {
gap: 16px;
}
.settings-quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.settings-quick-actions .primary-action,
.settings-quick-actions .secondary-action {
min-width: 210px;
}
.settings-shell .market-header-simple {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -1710,6 +1727,16 @@ select:focus {
color: var(--text-muted);
}
.persona-empty-state {
display: grid;
justify-items: start;
gap: 10px;
padding: 16px;
border-radius: 14px;
background: rgba(24, 32, 31, 0.04);
border: 1px dashed rgba(24, 32, 31, 0.14);
}
body[data-theme="fakegpt"] {
--bg: #0b0f0d;
--bg-soft: #111715;
+139 -15
View File
@@ -135,6 +135,10 @@ db.prepare(`
INTEGER,
user_persona_id
INTEGER,
scenario_prompt
TEXT,
scenario_summary
TEXT,
created_at
DATETIME
DEFAULT
@@ -331,7 +335,7 @@ const getLegacyChatsStmt = db.prepare(`
ORDER BY datetime(created_at)
`);
const insertChatSessionStmt = db.prepare(
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id) VALUES (?, ?, ?, ?)"
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?)"
);
const listChatSessionsStmt = db.prepare(`
SELECT cs.id,
@@ -344,6 +348,8 @@ const listChatSessionsStmt = db.prepare(`
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name,
cs.scenario_prompt,
cs.scenario_summary,
cs.created_at,
cs.updated_at
FROM chat_sessions cs
@@ -369,7 +375,9 @@ const getChatSessionStmt = db.prepare(`
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name
up.name AS user_persona_name,
cs.scenario_prompt,
cs.scenario_summary
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
@@ -387,7 +395,9 @@ const getChatSessionByParticipantsStmt = db.prepare(`
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name
up.name AS user_persona_name,
cs.scenario_prompt,
cs.scenario_summary
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
@@ -405,6 +415,10 @@ const updateChatSessionTitleStmt = db.prepare(
"UPDATE chat_sessions SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"
);
const updateChatSessionSceneStmt = db.prepare(
"UPDATE chat_sessions SET scenario_prompt = ?, scenario_summary = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"
);
const touchChatSessionStmt = db.prepare(
"UPDATE chat_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"
);
@@ -793,6 +807,14 @@ const hasChatUserPersonaLink = chatSessionColumns.some(column => column.name ===
if (!hasChatUserPersonaLink) {
db.prepare("ALTER TABLE chat_sessions ADD COLUMN user_persona_id INTEGER").run();
}
const hasScenarioPrompt = chatSessionColumns.some(column => column.name === "scenario_prompt");
if (!hasScenarioPrompt) {
db.prepare("ALTER TABLE chat_sessions ADD COLUMN scenario_prompt TEXT").run();
}
const hasScenarioSummary = chatSessionColumns.some(column => column.name === "scenario_summary");
if (!hasScenarioSummary) {
db.prepare("ALTER TABLE chat_sessions ADD COLUMN scenario_summary TEXT").run();
}
const FileStore = FileStoreFactory(session);
app.use(bodyParser.json());
@@ -1010,19 +1032,95 @@ const buildUserPersonaPrompt = (persona) => {
return lines.join("\n");
};
const createPersonaChat = (user, persona, userPersonaId = null) => {
const chatId = insertChatSessionStmt.run(user, persona.name, persona.id, userPersonaId).lastInsertRowid;
const summarizePersona = (persona) => {
if (!persona) return "";
return [
persona.name ? `Name: ${persona.name}` : null,
persona.pronouns ? `Pronouns: ${persona.pronouns}` : null,
persona.background ? `Background: ${persona.background}` : null,
persona.details ? `Traits: ${persona.details}` : null
].filter(Boolean).join(" | ");
};
const clampText = (value, limit = 260) => {
const normalized = String(value || "").replace(/\s+/g, " ").trim();
if (normalized.length <= limit) return normalized;
return `${normalized.slice(0, limit - 3).trimEnd()}...`;
};
const buildRoleplaySceneSummary = (assistantPersona, userPersona, scenarioPrompt = "") => {
const assistantName = assistantPersona?.name || "The assistant";
const userName = userPersona?.name || "the user";
const assistantHook = assistantPersona?.details || assistantPersona?.background || "stays strongly in character";
const userHook = userPersona?.details || userPersona?.background || "enters the scene as themselves";
const scenarioSeed = scenarioPrompt
? clampText(scenarioPrompt, 180)
: `A fitting opening grows naturally from ${assistantName}'s role, goals, and manner toward ${userName}.`;
return clampText(
`${assistantName} leads the opening. Their approach is shaped by ${assistantHook}. ` +
`${userName} is framed through ${userHook}. ` +
`Scene seed: ${scenarioSeed}`,
340
);
};
const buildRoleplayDirectionPrompt = ({assistantPersona, userPersona, scenarioPrompt, sceneSummary}) => {
const lines = [
"ROLEPLAY DIRECTION:",
"Create an immersive one-on-one roleplay between the assistant persona and the user.",
"Ground the interaction in a specific scenario, place, and immediate dramatic situation.",
"Make the assistant's first instinct, language, and priorities fit their persona exactly.",
"Use the user persona as interaction context so the assistant addresses them in a fitting way.",
"Avoid generic openings, meta commentary, and requests for permission to begin.",
"Do not write dialogue or internal thoughts for the user.",
"Prefer a concrete opening beat over vague exposition.",
"Include a subtle hook, tension, invitation, or problem that gives the user something to respond to.",
"",
`SCENE SUMMARY: ${sceneSummary}`
];
if (scenarioPrompt) {
lines.push(`USER SCENARIO SEED: ${scenarioPrompt}`);
}
if (assistantPersona) {
lines.push("", `ASSISTANT SNAPSHOT: ${summarizePersona(assistantPersona)}`);
}
if (userPersona) {
lines.push(`USER SNAPSHOT: ${summarizePersona(userPersona)}`);
}
return lines.join("\n");
};
const buildRoleplayOpenerPrompt = ({assistantPersona, userPersona, scenarioPrompt, sceneSummary}) => {
const assistantName = assistantPersona?.name || "the assistant";
const userName = userPersona?.name || "the user";
const sceneSeed = scenarioPrompt
? `Use this scenario seed: ${scenarioPrompt}`
: `Invent a fresh scenario that suits ${assistantName} and how they would realistically meet or confront ${userName}.`;
return [
`Write the first in-character message from ${assistantName}.`,
sceneSeed,
`Address ${userName} naturally within the scene.`,
"The message should establish the situation immediately instead of explaining setup out of character.",
"Use 1 to 3 short paragraphs. Sensory detail is allowed, but keep momentum.",
"End with a line, question, action, or reveal that gives the user an obvious way to answer.",
`Keep this scene continuity in mind: ${sceneSummary}`
].join("\n");
};
const createPersonaChat = (user, persona, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null) => {
const chatId = insertChatSessionStmt.run(user, persona.name, persona.id, userPersonaId, scenarioPrompt, scenarioSummary).lastInsertRowid;
return getChatSessionStmt.get(chatId, user);
};
const getOrCreatePersonaChat = (user, persona, userPersonaId = null) => {
const getOrCreatePersonaChat = (user, persona, userPersonaId = null, scenarioPrompt = null, scenarioSummary = 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);
return createPersonaChat(user, persona, userPersonaId, scenarioPrompt, scenarioSummary);
};
const ensureModelState = (model) => {
@@ -1132,7 +1230,7 @@ app.post("/chats", requireLogin, (req, res) => {
}
const title = (req.body?.title || "New chat").trim() || "New chat";
const chatId = insertChatSessionStmt.run(user, title, null, null).lastInsertRowid;
const chatId = insertChatSessionStmt.run(user, title, null, null, null, null).lastInsertRowid;
const chat = getChatSessionStmt.get(chatId, user);
res.json({chat, existing: false});
});
@@ -1281,7 +1379,7 @@ app.post("/personas/:id/chat", requireLogin, (req, res) => {
}
let chat;
try {
chat = getOrCreatePersonaChat(user, persona, userPersonaId);
chat = getOrCreatePersonaChat(user, persona, userPersonaId, null, null);
} 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."});
@@ -1450,9 +1548,11 @@ app.post("/personas/market/:id/chat", requireLogin, (req, res) => {
}
const persona = getPersonaStmt.get(personaId, user);
const scenarioPrompt = clampText(req.body?.scenarioPrompt || "", 240) || null;
const sceneSummary = buildRoleplaySceneSummary(persona, userPersona, scenarioPrompt);
let chat;
try {
chat = getOrCreatePersonaChat(user, persona, userPersonaId);
chat = getOrCreatePersonaChat(user, persona, userPersonaId, scenarioPrompt, sceneSummary);
} 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."});
@@ -1463,14 +1563,17 @@ app.post("/personas/market/:id/chat", requireLogin, (req, res) => {
if (existingMessages.length > 0) {
return res.json({persona, chat, existing: true, generatedInitialMessage: false});
}
updateChatSessionSceneStmt.run(scenarioPrompt, sceneSummary, chat.id, user);
chat = getChatSessionStmt.get(chat.id, user);
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."}
{role: "system", content: buildRoleplayDirectionPrompt({assistantPersona: persona, userPersona, scenarioPrompt, sceneSummary})},
{role: "user", content: buildRoleplayOpenerPrompt({assistantPersona: persona, userPersona, scenarioPrompt, sceneSummary})}
])).then((opener) => {
insertChatMessageStmt.run(chat.id, "bot", opener);
touchChatSessionStmt.run(chat.id, user);
res.json({persona, chat, existing: false, generatedInitialMessage: true, opener});
res.json({persona, chat: getChatSessionStmt.get(chat.id, user), existing: false, generatedInitialMessage: true, opener});
}).catch(() => {
res.json({persona, chat, existing: false, generatedInitialMessage: false});
});
@@ -1481,6 +1584,7 @@ app.post("/roleplays/start", requireLogin, async (req, res) => {
const selectedModel = req.body?.model || "mistral:latest";
const assistantPersonaId = Number(req.body?.assistantPersonaId);
const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null;
const scenarioPrompt = clampText(req.body?.scenarioPrompt || "", 240) || null;
if (!assistantPersonaId) {
return res.status(400).json({error: "assistantPersonaId is required"});
@@ -1500,8 +1604,9 @@ app.post("/roleplays/start", requireLogin, async (req, res) => {
}
let chat;
const sceneSummary = buildRoleplaySceneSummary(assistantPersona, userPersona, scenarioPrompt);
try {
chat = getOrCreatePersonaChat(user, assistantPersona, userPersonaId);
chat = getOrCreatePersonaChat(user, assistantPersona, userPersonaId, scenarioPrompt, sceneSummary);
} 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."});
@@ -1513,6 +1618,8 @@ app.post("/roleplays/start", requireLogin, async (req, res) => {
if (existingMessages.length > 0) {
return res.json({chat, existing: true, generatedInitialMessage: false});
}
updateChatSessionSceneStmt.run(scenarioPrompt, sceneSummary, chat.id, user);
chat = getChatSessionStmt.get(chat.id, user);
const messagesPayload = [
{role: "system", content: buildPersonaPrompt(assistantPersona)}
@@ -1520,9 +1627,13 @@ app.post("/roleplays/start", requireLogin, async (req, res) => {
if (userPersona) {
messagesPayload.push({role: "system", content: buildUserPersonaPrompt(userPersona)});
}
messagesPayload.push({
role: "system",
content: buildRoleplayDirectionPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary})
});
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."
content: buildRoleplayOpenerPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary})
});
try {
@@ -1532,7 +1643,7 @@ app.post("/roleplays/start", requireLogin, async (req, res) => {
}
insertChatMessageStmt.run(chat.id, "bot", opener);
touchChatSessionStmt.run(chat.id, user);
return res.json({chat, existing: false, generatedInitialMessage: true, opener});
return res.json({chat: getChatSessionStmt.get(chat.id, user), existing: false, generatedInitialMessage: true, opener});
} catch {
return res.status(500).json({error: "Failed to generate the character opener"});
}
@@ -1556,6 +1667,8 @@ app.post("/chat", requireLogin, async (req, res) => {
try {
const activePersona = getAssistantPersonaForChatStmt.get(targetChatId, user);
const activeUserPersona = getUserPersonaForChatStmt.get(targetChatId, user) || getActiveUserPersonaStmt.get(user);
const sceneSummary = session.scenario_summary;
const scenarioPrompt = session.scenario_prompt;
const history = getRecentChatMessagesStmt.all(targetChatId);
const conversation = [...history].reverse();
conversation.push({role: "user", content: message});
@@ -1574,6 +1687,17 @@ app.post("/chat", requireLogin, async (req, res) => {
content: buildUserPersonaPrompt(activeUserPersona)
});
}
if (activePersona && sceneSummary) {
messagesPayload.push({
role: "system",
content: buildRoleplayDirectionPrompt({
assistantPersona: activePersona,
userPersona: activeUserPersona,
scenarioPrompt,
sceneSummary
})
});
}
messagesPayload.push(
...conversation.map(m => ({
role: m.role === "bot" ? "assistant" : "user",