Overhaul legal page and global styles for consistency and responsiveness.

This commit is contained in:
2026-08-09 18:14:13 +02:00
parent 20b5129f57
commit cb330b332e
18 changed files with 1055 additions and 6802 deletions
+11 -5
View File
@@ -69,13 +69,19 @@ Legacy `chats` migration is handled during startup. New code only reads the sess
## Frontend Structure
The main browser entrypoint is still `public/script.js`, but shared API/bootstrap concerns are now split into `public/app/` modules:
The browser UI remains framework-free and uses native ES modules. Shared behavior lives under `public/app/`:
- `api.js`: JSON request helpers
- `constants.js`: theme and onboarding constants
- `dom.js`: cached DOM references
- `api.js`: JSON and streaming request helpers
- `constants.js`: theme and onboarding metadata
- `themeController.js`: validated theme/mode persistence and application
- `theme-bootstrap.js`: applies stored appearance before page initialization
- `personaForm.js`: shared persona form serialization
- `statusNotice.js`: shared status and error presentation
- `dom.js`: cached chat-page DOM references
This keeps the next round of UI extraction lower-risk without changing the app's behavior surface.
`public/style.css` is a self-contained design system rebuilt around semantic tokens, shared components, and deliberate responsive breakpoints. It contains the six visual identities and their dark variants without legacy imports or precedence layers. `public/legal.css` contains only the legal-page layout.
Pure chat message, retry, memory, summary, and persona-state formatting lives in `src/services/chatMessageHelpers.js`; `chatService.js` remains responsible for orchestration and persistence.
## Legal
+15
View File
@@ -58,6 +58,20 @@ function toggleChatDrawer() {
syncChatDrawerState(!isChatDrawerOpen());
}
function handleDisclosureToggle(event) {
const disclosure = event.target;
if (!(disclosure instanceof HTMLDetailsElement) || !chatDrawer?.contains(disclosure)) return;
if (disclosure.matches(".chat-drawer-more, .chat-drawer-danger")) {
const hint = disclosure.querySelector(":scope > summary .chat-drawer-row-hint");
if (hint) hint.textContent = disclosure.open ? "Hide" : "Show";
}
if (disclosure.open) {
chatDrawer.querySelectorAll("details[open]").forEach((other) => {
if (other !== disclosure) other.open = false;
});
}
}
function handleDocumentClick(event) {
if (!isChatDrawerOpen()) return;
const target = event.target;
@@ -103,6 +117,7 @@ if (chatDrawerCloseBtn) {
document.addEventListener("click", handleDocumentClick);
document.addEventListener("keydown", handleDocumentKeydown);
chatDrawer?.addEventListener("toggle", handleDisclosureToggle, true);
export {
closeChatDrawer,
+6
View File
@@ -8,6 +8,8 @@ const messagesDiv = $("messages");
const modelSelect = $("model");
const modelPanel = $("modelPanel");
const modelBadgeName = $("modelBadgeName");
const headerModelName = $("headerModelName");
const headerPersonaName = $("headerPersonaName");
const modelCount = $("modelCount");
const modelHelpTitle = $("modelHelpTitle");
const modelHelpBadge = $("modelHelpBadge");
@@ -41,6 +43,7 @@ const exportChatBtn = $("exportChat");
const pinChatBtn = $("pinChat");
const moveChatFolderBtn = $("moveChatFolder");
const manageChatMemoryBtn = $("manageChatMemory");
const managePersonaStateBtn = $("managePersonaState");
const archiveChatBtn = $("archiveChat");
const backupWorkspaceBtn = $("backupWorkspace");
const importWorkspaceBtn = $("importWorkspace");
@@ -139,6 +142,7 @@ export {
pinChatBtn,
moveChatFolderBtn,
manageChatMemoryBtn,
managePersonaStateBtn,
archiveChatBtn,
backupWorkspaceBtn,
importWorkspaceBtn,
@@ -150,6 +154,8 @@ export {
logoutButton,
messagesDiv,
modelBadgeName,
headerModelName,
headerPersonaName,
modelCount,
modelHelpBadge,
modelHelpSummary,
+21 -16
View File
@@ -6,10 +6,11 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css"/>
<script src="app/theme-bootstrap.js"></script>
<link rel="stylesheet" href="style.css?v=20260809-2"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<body data-theme="fakegpt" data-theme-mode="light">
<div class="app-shell">
<section id="auth" class="card auth-card auth-card-compact">
<div class="auth-hero auth-hero-compact">
@@ -72,7 +73,7 @@
</div>
</section>
<section id="chat" class="card chat-card chat-focus-card" aria-label="AI chat workspace" style="display:none;">
<section id="chat" class="card chat-card chat-focus-card hidden" aria-label="AI chat workspace">
<div class="chat-layout chat-layout-focus">
<aside id="chatSidebar" class="chat-sidebar" aria-label="Workspace navigation">
<div class="sidebar-top">
@@ -111,24 +112,27 @@
<div class="chat-title">
<h2 id="activeChatTitle">New chat</h2>
</div>
<div class="chat-header-context" aria-label="Current chat setup">
<span class="chat-context-item"><span>Model</span><strong id="headerModelName">Loading...</strong></span>
<span class="chat-context-item"><span>Persona</span><strong id="headerPersonaName">None</strong></span>
</div>
</div>
<div class="chat-header-actions">
<div class="chat-actions-menu">
<button id="chatDrawerButton" class="secondary-action overflow-trigger" type="button" aria-expanded="false" aria-controls="chatDrawer">
<span class="overflow-trigger-label">Controls</span>
<span class="overflow-trigger-label">Chat settings</span>
</button>
<div id="chatDrawer" class="chat-drawer hidden" role="dialog" aria-modal="true" aria-labelledby="chatDrawerTitle">
<div class="chat-drawer-panel">
<div class="chat-drawer-header">
<div>
<p class="eyebrow">Conversation</p>
<h2 id="chatDrawerTitle">Chat controls</h2>
<h2 id="chatDrawerTitle">Settings</h2>
</div>
<button id="chatDrawerClose" class="icon-button" type="button" aria-label="Close panel">Close</button>
<button id="chatDrawerClose" class="icon-button" type="button" aria-label="Close chat settings">×</button>
</div>
<div class="chat-drawer-body">
<div class="chat-drawer-summary">
<p class="dropdown-section-label">Current mode</p>
<div class="chat-drawer-pills">
<span id="chatCharacterPill" class="context-pill hidden">Character: none</span>
<span id="chatUserPersonaPill" class="context-pill hidden">User persona: none</span>
@@ -137,8 +141,8 @@
<details class="chat-drawer-section chat-drawer-disclosure chat-drawer-section--model">
<summary class="chat-drawer-row">
<span class="chat-drawer-row-label">
<span class="eyebrow">Model</span>
<strong>Engine</strong>
<span class="eyebrow">AI model</span>
<strong>Model</strong>
</span>
<span class="chat-drawer-row-meta">
<strong id="modelBadgeName">Loading...</strong>
@@ -170,8 +174,8 @@
<details class="chat-drawer-section chat-drawer-disclosure chat-drawer-section--persona">
<summary class="chat-drawer-row">
<span class="chat-drawer-row-label">
<span class="eyebrow">Persona</span>
<strong>Identity</strong>
<span class="eyebrow">Your identity</span>
<strong>Persona</strong>
</span>
<span class="chat-drawer-row-meta">
<strong id="chatDrawerPersonaValue">None</strong>
@@ -195,7 +199,7 @@
</div>
</details>
<div class="chat-drawer-section">
<p class="dropdown-section-label">Current chat</p>
<p class="dropdown-section-label">Chat</p>
<div class="chat-drawer-action-list">
<button id="renameChat" class="secondary-action chat-drawer-action" type="button" data-chat-action="rename">Rename chat</button>
<button id="pinChat" class="secondary-action chat-drawer-action" type="button" data-chat-action="pin">Pin</button>
@@ -206,13 +210,14 @@
<details class="chat-drawer-section chat-drawer-more">
<summary class="chat-drawer-row chat-drawer-row--compact">
<span class="chat-drawer-row-label">
<strong>More actions</strong>
<strong>Tools &amp; workspace</strong>
</span>
<span class="chat-drawer-row-hint">Expand</span>
<span class="chat-drawer-row-hint">Show</span>
</summary>
<div class="chat-drawer-action-list chat-drawer-disclosure-panel">
<button id="moveChatFolder" class="secondary-action chat-drawer-action" type="button" data-chat-action="folder">Move to folder</button>
<button id="manageChatMemory" class="secondary-action chat-drawer-action" type="button" data-chat-action="memory">Memory facts</button>
<button id="managePersonaState" class="secondary-action chat-drawer-action" type="button" data-chat-action="personaState">Roleplay state</button>
<button id="backupWorkspace" class="secondary-action chat-drawer-action" type="button">Backup workspace</button>
<button id="importWorkspace" class="secondary-action chat-drawer-action" type="button">Import workspace</button>
<input id="importWorkspaceFile" class="hidden" type="file" accept="application/json,.json"/>
@@ -222,9 +227,9 @@
<details class="chat-drawer-section chat-drawer-danger">
<summary class="chat-drawer-row chat-drawer-row--compact">
<span class="chat-drawer-row-label">
<strong>Danger zone</strong>
<strong>Clear conversation</strong>
</span>
<span class="chat-drawer-row-hint">Expand</span>
<span class="chat-drawer-row-hint">Show</span>
</summary>
<div class="chat-drawer-action-list chat-drawer-disclosure-panel">
<button id="clearChat" class="secondary-action chat-drawer-action chat-drawer-action-danger" type="button" data-chat-action="clear">Clear chat</button>
+17 -45
View File
@@ -1,49 +1,21 @@
body.legal-page {
display: block;
height: auto;
background-attachment: fixed;
background-repeat: no-repeat;
background-size: cover;
min-height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
.legal-page {
min-height: calc(100dvh - 36px);
display: grid;
place-items: center;
}
.legal-container,
.legal-content {
max-width: 900px;
margin: 0 auto;
padding: 2rem 1.5rem;
color: var(--text-primary);
font-family: inherit;
width: min(860px, 100%);
padding: clamp(24px, 5vw, 52px);
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
}
.legal-content h1,
.legal-content h2 {
color: var(--text-primary);
}
.legal-content p,
.legal-content li {
line-height: 1.6;
color: var(--text-secondary);
}
.legal-content * {
overflow-wrap: break-word;
word-break: break-word;
}
@media (max-width: 600px) {
.legal-content {
padding: 1.25rem 1rem;
}
.legal-content h1 {
font-size: 1.6rem;
}
.legal-content h2 {
font-size: 1.2rem;
}
.legal-container h1, .legal-content h1 { margin-bottom: 20px; }
.legal-container h2, .legal-content h2 { margin: 28px 0 10px; font-size: 1.25rem; }
.legal-container p, .legal-container li, .legal-content p, .legal-content li { color: var(--text-soft); line-height: 1.75; }
.legal-container a, .legal-content a { font-weight: 700; }
@media (max-width: 700px) {
.legal-container, .legal-content { min-height: 100dvh; border: 0; border-radius: 0; }
}
+4 -3
View File
@@ -6,10 +6,11 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css"/>
<script src="app/theme-bootstrap.js"></script>
<link rel="stylesheet" href="style.css?v=20260809-2"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<body data-theme="fakegpt" data-theme-mode="light">
<div class="market-shell card market-shell-simple">
<header class="market-header market-header-simple">
<div>
@@ -237,6 +238,6 @@
</div>
</div>
</div>
<script src="market.js" defer></script>
<script type="module" src="market.js"></script>
</body>
</html>
+18 -59
View File
@@ -1,3 +1,8 @@
import {get, post, put} from "./app/api.js";
import {initializeAppearance} from "./app/themeController.js";
import {clearStatus, setStatus} from "./app/statusNotice.js";
import {populatePersonaForm, readPersonaForm} from "./app/personaForm.js";
const $ = (id) => document.getElementById(id);
const marketPersonaList = $("marketPersonaList");
const marketUserPersonaList = $("marketUserPersonaList");
@@ -55,14 +60,13 @@ const marketPopupField = $("marketPopupField");
const marketPopupInputLabel = $("marketPopupInputLabel");
const marketPopupInput = $("marketPopupInput");
const themeNameTargets = document.querySelectorAll("[data-theme-name]");
const themes = {
"fakegpt": {name: "FakeGPT"},
"fraud": {name: "Fraud"},
"germini": {name: "Germini"},
"slopilot": {name: "Slopilot"},
"beta-ai": {name: "Beta AI"},
"confusity": {name: "Confusity"}
const marketPersonaFields = {
name: marketPersonaName,
pronouns: marketPersonaPronouns,
appearance: marketPersonaAppearance,
background: marketPersonaBackground,
details: marketPersonaDetails,
exampleDialogues: marketPersonaExampleDialogues
};
const validMarketSorts = new Set(["best", "newest", "most_favorited", "most_popular", "top_rated", "alphabetical"]);
@@ -78,40 +82,8 @@ let marketActivityDepth = 0;
let marketPreviewStep = 1;
let marketPopupResolver = null;
async function request(url, data, method = "POST") {
try {
const res = await fetch(url, {
method,
credentials: "same-origin",
headers: {"Content-Type": "application/json"},
body: data ? JSON.stringify(data) : undefined
});
return await res.json();
} catch {
return {error: "Network error - please try again."};
}
}
const get = (url) => request(url, null, "GET");
const post = (url, data) => request(url, data, "POST");
const put = (url, data) => request(url, data, "PUT");
function applyTheme(themeKey) {
const nextTheme = themes[themeKey] ? themeKey : "fakegpt";
document.body.dataset.theme = nextTheme;
document.title = `${themes[nextTheme].name} Market`;
themeNameTargets.forEach((target) => {
target.textContent = themes[nextTheme].name;
});
}
function applyThemeMode(modeKey) {
document.body.dataset.themeMode = modeKey === "dark" ? "dark" : "light";
}
function setMarketStatus(message, state = "") {
marketStatus.textContent = message;
marketStatus.className = state ? `status persona-status ${state}` : "status persona-status";
setStatus(marketStatus, message, state, "status persona-status");
}
function closeMarketPopup(value = null) {
@@ -150,12 +122,10 @@ function promptMarketPopup({
function setPersonaFormNotice(message = "", state = "") {
if (!message) {
marketPersonaFormNotice.textContent = "";
marketPersonaFormNotice.className = "status hidden";
clearStatus(marketPersonaFormNotice);
return;
}
marketPersonaFormNotice.textContent = message;
marketPersonaFormNotice.className = state ? `status ${state}` : "status";
setStatus(marketPersonaFormNotice, message, state);
}
function setMarketActivity(active, {
@@ -351,12 +321,7 @@ function openMarketPreview(persona) {
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 || "";
populatePersonaForm(marketPersonaFields, persona || {});
setPersonaFormNotice("");
marketPersonaModal.classList.remove("hidden");
marketPersonaName.focus();
@@ -600,13 +565,8 @@ async function startMarketPersonaChat(marketId, userPersonaSelection, scenarioPr
async function savePersona() {
const payload = {
...readPersonaForm(marketPersonaFields),
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) {
@@ -713,8 +673,7 @@ marketPersonaForm.addEventListener("submit", (event) => {
});
window.addEventListener("load", async () => {
applyTheme(localStorage.getItem("krishd-theme") || "fakegpt");
applyThemeMode(localStorage.getItem("krishd-theme-mode") || "light");
initializeAppearance({title: (theme) => `${theme.name} Market`, nameTargets: themeNameTargets});
const params = new URLSearchParams(window.location.search);
setMarketSort("assistant", params.get("assistantSort") || "best", {reload: false});
setMarketSort("user", params.get("userSort") || "best", {reload: false});
+96 -26
View File
@@ -1,6 +1,7 @@
import {del, get, post, put, stream} from "./app/api.js";
import {closeChatDrawer} from "./app/chatDrawer.js";
import {defaultModelProfile, onboardingPrompts, requestedChatId, themes} from "./app/constants.js";
import {defaultModelProfile, onboardingPrompts, requestedChatId} from "./app/constants.js";
import {applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, readStoredAppearance} from "./app/themeController.js";
import {
activeChatTitle,
activeUserPersonaStatus,
@@ -28,7 +29,10 @@ import {
exportChatBtn,
importWorkspaceBtn,
manageChatMemoryBtn,
managePersonaStateBtn,
importWorkspaceFileInput,
headerModelName,
headerPersonaName,
moveChatFolderBtn,
loginForm,
loginPasswordInput,
@@ -559,6 +563,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
if (isLoading) {
msgDiv.setAttribute("aria-label", "Assistant is responding");
msgDiv.innerHTML = `
<div class="msg-header">Assistant</div>
<div class="loading-shell" aria-live="polite" aria-label="Assistant is responding">
<div class="loading-bars">
<span class="loading-bar"></span>
@@ -569,7 +574,8 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
<div class="loading-copy">
<strong>Thinking</strong>
</div>
</div>`;
</div>
<div class="msg-content streaming-content"></div>`;
} else {
const message = typeof contentOrMessage === "object" && contentOrMessage
? contentOrMessage
@@ -728,7 +734,13 @@ function renderMessageEmptyState() {
createStarterAction("Change setup", "secondary-action", () => openRoleplayStarter())
);
} else {
return;
title.textContent = "What can I help with?";
description.textContent = "Write a message below or start with one of these prompts.";
actions.append(
createMessageStarterPrompt("Explain something", "Explain this topic in simple terms: "),
createMessageStarterPrompt("Plan a task", "Help me make a practical step-by-step plan for: "),
createMessageStarterPrompt("Brainstorm ideas", "Give me several creative ideas for: ")
);
}
empty.append(title, description, actions);
@@ -849,6 +861,7 @@ function updateModelHelp() {
if (!selectedOption || selectedOption.disabled) {
if (modelCount) modelCount.textContent = "No models";
if (modelBadgeName) modelBadgeName.textContent = "Unavailable";
if (headerModelName) headerModelName.textContent = "Unavailable";
modelHelpBadge.dataset.badge = "waiting";
modelHelpTitle.textContent = "Choose a model";
modelHelpBadge.textContent = "Waiting";
@@ -863,6 +876,7 @@ function updateModelHelp() {
const modelName = selectedOption.textContent || modelId;
const profile = getModelProfile(modelId, modelName);
if (modelBadgeName) modelBadgeName.textContent = modelName;
if (headerModelName) headerModelName.textContent = modelName;
modelHelpTitle.textContent = modelName;
modelHelpBadge.textContent = profile.badge;
modelHelpBadge.dataset.badge = profile.badge.toLowerCase();
@@ -870,21 +884,18 @@ function updateModelHelp() {
}
function applyTheme(themeKey, persist = true) {
const nextTheme = themes[themeKey] ? themeKey : "fakegpt";
const theme = themes[nextTheme];
document.body.dataset.theme = nextTheme;
document.title = theme.name;
themeNameTargets.forEach((target) => { target.textContent = theme.name; });
themeLogoTargets.forEach((target) => { target.textContent = theme.short; });
if (persist) localStorage.setItem("krishd-theme", nextTheme);
applySharedTheme(themeKey, {
persist,
title: (theme) => theme.name,
nameTargets: themeNameTargets,
logoTargets: themeLogoTargets
});
updateWorkspaceCopy();
renderModelSection();
}
function applyThemeMode(modeKey, persist = true) {
const nextMode = modeKey === "dark" ? "dark" : "light";
document.body.dataset.themeMode = nextMode;
if (persist) localStorage.setItem("krishd-theme-mode", nextMode);
applySharedThemeMode(modeKey, {persist});
}
function updateChatActionState() {
@@ -897,6 +908,7 @@ function updateChatActionState() {
}
if (moveChatFolderBtn) moveChatFolderBtn.disabled = !hasChat;
if (manageChatMemoryBtn) manageChatMemoryBtn.disabled = !hasChat;
if (managePersonaStateBtn) managePersonaStateBtn.disabled = !hasChat || !activeChat?.assistant_persona_id;
if (archiveChatBtn) {
archiveChatBtn.disabled = !hasChat;
if (hasChat) archiveChatBtn.textContent = activeChat.archived_at ? "Unarchive" : "Archive";
@@ -912,6 +924,7 @@ function renderPersonaSection() {
if (chatDrawerPersonaValue) {
const active = userPersonas.find((persona) => persona.id === activeUserPersonaId);
chatDrawerPersonaValue.textContent = active ? active.name : "None";
if (headerPersonaName) headerPersonaName.textContent = active ? active.name : "None";
}
if (clearUserPersonaBtn) clearUserPersonaBtn.disabled = !activeUserPersonaId;
}
@@ -1280,7 +1293,11 @@ function setLoadingState(loading, overlayOptions = null) {
sendBtn.classList.toggle("loading", loading);
if (loading) {
msgInput.placeholder = "Processing response...";
setChatActivity(true, overlayOptions || undefined);
if (overlayOptions === false) {
setChatActivity(false);
} else {
setChatActivity(true, overlayOptions || undefined);
}
} else {
setChatActivity(false);
updateComposerPlaceholder();
@@ -1590,6 +1607,54 @@ async function manageChatMemory() {
return setNotice("Memory fact saved.", "success");
}
const personaStateFields = [
["relationship_notes", "Relationship notes"],
["tone", "Tone"],
["current_location", "Current location"],
["goals", "Goals"],
["unresolved_threads", "Unresolved threads"],
["boundaries", "Boundaries"]
];
function formatPersonaState(state = {}) {
const lines = personaStateFields.map(([key, label], index) => {
const value = String(state[key] || "").trim();
return `${index + 1}. ${label}: ${value || "Not set"}`;
});
return lines.join("\n");
}
async function managePersonaState() {
if (!activeChatId) return;
const loaded = await get(`/chats/${activeChatId}/persona-state`);
if (loaded.error) return setNotice(loaded.error, "error");
const state = loaded.state || {};
const fieldChoice = await promptPopup({
eyebrow: "Roleplay",
title: "Roleplay state",
description: `${formatPersonaState(state)}\n\nType a field number to edit it.`,
label: "Field number",
placeholder: "1",
confirmLabel: "Next"
});
const selected = personaStateFields[Number(String(fieldChoice || "").trim()) - 1];
if (!selected) return;
const [key, label] = selected;
const value = await promptPopup({
eyebrow: "Roleplay",
title: label,
description: "Leave blank to clear this field.",
label,
value: state[key] || "",
placeholder: label
});
if (value === null) return;
const nextState = {...state, [key]: String(value || "").trim() || null};
const saved = await put(`/chats/${activeChatId}/persona-state`, nextState);
if (saved.error) return setNotice(saved.error, "error");
return setNotice("Roleplay state saved.", "success");
}
async function deleteChat(id) {
setChatLoading(true, "Deleting chat...");
const chat = getChatById(id);
@@ -1647,7 +1712,7 @@ async function checkSession() {
const res = await get("/session");
if (registerInviteField) registerInviteField.classList.toggle("hidden", !res.inviteOnlyRegistration);
if (!res.user) return false;
currentUsername = res.user; authDiv.style.display = "none"; chatDiv.style.display = "block";
currentUsername = res.user; authDiv.classList.add("hidden"); chatDiv.classList.remove("hidden");
await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]);
maybeShowOnboarding();
msgInput.focus(); return true;
@@ -1664,7 +1729,7 @@ async function handleAuth(endpoint, credentials) {
submitBtn.disabled = false;
if (res.error) return setAuthMessage(res.error, "error");
if (endpoint === "login") {
currentUsername = username; authDiv.style.display = "none"; chatDiv.style.display = "block";
currentUsername = username; authDiv.classList.add("hidden"); chatDiv.classList.remove("hidden");
await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]);
maybeShowOnboarding();
setNotice("Ready.", "success"); msgInput.focus();
@@ -1687,18 +1752,19 @@ async function sendMessage() {
currentMessages.push({role: "user", content: message}); addMessage(message, true); updateChatActionState();
msgInput.value = "";
resizeComposerInput();
setLoadingState(true, {
eyebrow: "Assistant replying",
title: "Generating response",
detail: "The assistant is reading your message and preparing a reply."
}); setNotice("Generating reply...");
const loadingMsg = addMessage({role: "bot", content: ""}, false, false, {isNewest: true});
setLoadingState(true, false); setNotice("Generating reply...");
const loadingMsg = addMessage({role: "bot", content: ""}, false, true, {isNewest: true});
const loadingBody = loadingMsg.querySelector(".msg-content");
const loadingShell = loadingMsg.querySelector(".loading-shell");
try {
let streamError = null;
await stream("/chat/stream", {message, model: modelSelect.value, chatId: activeChatId}, {
onChunk: ({fullReply}) => {
if (loadingBody) setMessageContent(loadingBody, fullReply);
if (loadingBody) {
loadingShell?.classList.add("hidden");
loadingBody.classList.add("has-content");
setMessageContent(loadingBody, fullReply);
}
},
onDone: () => {},
onError: ({error}) => {
@@ -1752,6 +1818,9 @@ const chatActions = {
memory: async () => {
await manageChatMemory();
},
personaState: async () => {
await managePersonaState();
},
pin: async () => {
const chat = getChatById(activeChatId);
if (chat) await updateChatOrganization(chat.id, {isPinned: !chat.is_pinned});
@@ -1776,7 +1845,7 @@ loginForm.addEventListener("submit", (event) => { event.preventDefault(); void h
registerForm.addEventListener("submit", (event) => { event.preventDefault(); void handleAuth("register", {username: registerUsernameInput.value.trim(), password: registerPasswordInput.value.trim()}); });
logoutButton.addEventListener("click", async () => {
closeChatDrawer();
await post("/logout", {}); chatDiv.style.display = "none"; authDiv.style.display = "grid";
await post("/logout", {}); chatDiv.classList.add("hidden"); authDiv.classList.remove("hidden");
activeChatId = null; currentUsername = ""; currentSummary = null; chatSessions = []; currentMessages = []; assistantPersonas = []; userPersonas = [];
messageRetryState = new Map();
activeUserPersonaId = null; publishedPersonaIds = new Set(); messagesDiv.innerHTML = ""; renderChatList(); updateChatActionState();
@@ -1904,8 +1973,9 @@ document.addEventListener("keydown", (event) => {
}
});
window.addEventListener("load", async () => {
applyTheme(localStorage.getItem("krishd-theme") || "fakegpt", false);
applyThemeMode(localStorage.getItem("krishd-theme-mode") || "light", false);
const appearance = readStoredAppearance();
applyTheme(appearance.theme, false);
applyThemeMode(appearance.mode, false);
applyWorkspaceMode(localStorage.getItem("krishd-workspace-mode") || "basic", false);
setNotice("Ready.");
updateChatActionState();
+4 -3
View File
@@ -6,10 +6,11 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css"/>
<script src="app/theme-bootstrap.js"></script>
<link rel="stylesheet" href="style.css?v=20260809-2"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<body data-theme="fakegpt" data-theme-mode="light">
<div class="settings-shell card">
<header class="market-header market-header-simple">
<div>
@@ -298,6 +299,6 @@
</div>
</div>
</div>
<script src="settings.js" defer></script>
<script type="module" src="settings.js"></script>
</body>
</html>
+24 -55
View File
@@ -1,3 +1,12 @@
import {del, get, post, put} from "./app/api.js";
import {
applyTheme as applySharedTheme,
applyThemeMode as applySharedThemeMode,
initializeAppearance
} from "./app/themeController.js";
import {clearStatus, setStatus} from "./app/statusNotice.js";
import {populatePersonaForm, readPersonaForm} from "./app/personaForm.js";
const $ = (id) => document.getElementById(id);
const settingsNotice = $("settingsNotice");
const settingsThemeSelect = $("settingsThemeSelect");
@@ -56,14 +65,13 @@ const settingsPopupDescription = $("settingsPopupDescription");
const settingsPopupField = $("settingsPopupField");
const settingsPopupInputLabel = $("settingsPopupInputLabel");
const settingsPopupInput = $("settingsPopupInput");
const themes = {
"fakegpt": {name: "FakeGPT"},
"fraud": {name: "Fraud"},
"germini": {name: "Germini"},
"slopilot": {name: "Slopilot"},
"beta-ai": {name: "Beta AI"},
"confusity": {name: "Confusity"}
const settingsPersonaFields = {
name: settingsPersonaName,
pronouns: settingsPersonaPronouns,
appearance: settingsPersonaAppearance,
background: settingsPersonaBackground,
details: settingsPersonaDetails,
exampleDialogues: settingsPersonaExampleDialogues
};
let assistantPersonas = [];
@@ -73,38 +81,16 @@ let editingPersonaId = null;
let settingsPopupResolver = null;
let isAdminUser = false;
async function request(url, data, method = "POST") {
try {
const res = await fetch(url, {
method,
credentials: "same-origin",
headers: {"Content-Type": "application/json"},
body: data ? JSON.stringify(data) : undefined
});
return await res.json();
} catch {
return {error: "Network error - please try again."};
}
}
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 = "") {
settingsNotice.textContent = message;
settingsNotice.className = state ? `status ${state}` : "status";
setStatus(settingsNotice, message, state);
}
function setPersonaNotice(message = "", state = "") {
if (!message) {
settingsPersonaFormNotice.textContent = "";
settingsPersonaFormNotice.className = "status hidden";
clearStatus(settingsPersonaFormNotice);
return;
}
settingsPersonaFormNotice.textContent = message;
settingsPersonaFormNotice.className = state ? `status ${state}` : "status";
setStatus(settingsPersonaFormNotice, message, state);
}
function closeSettingsPopup(value = null) {
@@ -141,18 +127,11 @@ function promptSettingsPopup({
}
function applyTheme(themeKey, persist = true) {
const nextTheme = themes[themeKey] ? themeKey : "fakegpt";
document.body.dataset.theme = nextTheme;
document.title = "Settings";
settingsThemeSelect.value = nextTheme;
if (persist) localStorage.setItem("krishd-theme", nextTheme);
return applySharedTheme(themeKey, {persist, title: "Settings", themeSelect: settingsThemeSelect});
}
function applyThemeMode(modeKey, persist = true) {
const nextMode = modeKey === "dark" ? "dark" : "light";
document.body.dataset.themeMode = nextMode;
if (themeModeSelect) themeModeSelect.value = nextMode;
if (persist) localStorage.setItem("krishd-theme-mode", nextMode);
return applySharedThemeMode(modeKey, {persist, modeSelect: themeModeSelect});
}
function setSettingsView(view) {
@@ -247,12 +226,7 @@ function openPersonaModal(persona = null, personaType = "assistant") {
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 || "";
populatePersonaForm(settingsPersonaFields, persona || {});
syncPersonaExamplesField();
setPersonaNotice("");
settingsPersonaModal.classList.remove("hidden");
@@ -359,12 +333,8 @@ async function loadPersonas() {
async function savePersona() {
const wasEditing = Boolean(editingPersonaId);
const payload = {
...readPersonaForm(settingsPersonaFields),
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() : ""
};
@@ -602,8 +572,7 @@ settingsPopupInput.addEventListener("keydown", (event) => {
});
window.addEventListener("load", async () => {
applyTheme(localStorage.getItem("krishd-theme") || "fakegpt", false);
applyThemeMode(localStorage.getItem("krishd-theme-mode") || "light", false);
initializeAppearance({title: "Settings", themeSelect: settingsThemeSelect, modeSelect: themeModeSelect});
workspaceModeSelect.value = localStorage.getItem("krishd-workspace-mode") || "basic";
const params = new URLSearchParams(window.location.search);
const view = params.get("view") || "personal";
+563 -6516
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -1,4 +1,5 @@
import fs from "fs";
import {randomUUID} from "crypto";
import {performance} from "perf_hooks";
import express from "express";
import session from "express-session";
@@ -70,6 +71,78 @@ function createLifecycleMonitors(app, config) {
return resources;
}
function safeRequestId(value) {
const candidate = String(value || "").trim();
if (/^[A-Za-z0-9._:-]{8,128}$/.test(candidate)) return candidate;
return randomUUID();
}
function createObservability(config) {
const counters = {
startedAt: new Date().toISOString(),
requestsTotal: 0,
responsesByStatus: {},
errorsTotal: 0,
slowRequestsTotal: 0,
lastFailure: null
};
const recordError = (req, error, status = 500) => {
counters.errorsTotal += 1;
counters.lastFailure = {
at: new Date().toISOString(),
requestId: req?.id || null,
method: req?.method || null,
path: req?.originalUrl || req?.url || null,
status,
message: String(error?.message || error || "Unknown error").slice(0, 500)
};
};
const middleware = (req, res, next) => {
const start = Date.now();
req.id = safeRequestId(req.get("X-Request-ID"));
res.setHeader("X-Request-ID", req.id);
res.on("finish", () => {
const durationMs = Date.now() - start;
const status = res.statusCode;
const statusKey = String(status);
counters.requestsTotal += 1;
counters.responsesByStatus[statusKey] = (counters.responsesByStatus[statusKey] || 0) + 1;
if (durationMs > config.slowRequestWarnMs) counters.slowRequestsTotal += 1;
if (status >= 500 && counters.lastFailure?.requestId !== req.id) {
recordError(req, `HTTP ${status}`, status);
}
if (config.structuredRequestLoggingEnabled) {
console.log(JSON.stringify({
type: "request",
requestId: req.id,
method: req.method,
path: req.originalUrl || req.url,
status,
durationMs,
slow: durationMs > config.slowRequestWarnMs,
user: req.session?.user || null
}));
}
});
next();
};
return {
middleware,
recordError,
getSnapshot() {
return {
...counters,
responsesByStatus: {...counters.responsesByStatus}
};
}
};
}
function wantsJson(req) {
const accept = req.get("Accept") || "";
const contentType = req.get("Content-Type") || "";
@@ -92,11 +165,13 @@ function errorHandler(err, req, res, next) {
}
if (err?.type === "entity.parse.failed") {
req.app?.locals?.observability?.recordError(req, err, 400);
return res.status(400).json({error: "Invalid JSON"});
}
const status = Number.isInteger(err.status) ? err.status : Number.isInteger(err.statusCode) ? err.statusCode : 500;
const message = status >= 500 ? "Internal server error" : err.message;
req.app?.locals?.observability?.recordError(req, err, status);
if (status >= 500) {
console.warn("REQUEST ERROR", status, req.method, req.url, err);
} else {
@@ -165,6 +240,7 @@ export function createApp(options = {}) {
}
const closeHandlers = createLifecycleMonitors(app, config);
const observability = createObservability(config);
const db = createDatabase(config);
const repositories = createRepositories(db, config);
@@ -179,6 +255,7 @@ export function createApp(options = {}) {
() => db.close()
);
app.use(observability.middleware);
app.use(express.json({limit: "1mb"}));
app.use(express.static(config.publicDir, {
maxAge: config.staticMaxAge,
@@ -204,6 +281,7 @@ export function createApp(options = {}) {
app.locals.db = db;
app.locals.repositories = repositories;
app.locals.modelService = modelService;
app.locals.observability = observability;
app.locals.close = createCloseHandler(closeHandlers);
app.use(createAuthRouter({repositories, authRateLimiters, config}));
+1
View File
@@ -46,6 +46,7 @@ export function createConfig(overrides = {}) {
eventLoopLagWarnMs: int(process.env.EVENT_LOOP_LAG_WARN_MS, 50),
slowRequestLoggingEnabled: bool(process.env.SLOW_REQUEST_LOGGING_ENABLED, !testMode),
slowRequestWarnMs: int(process.env.SLOW_REQUEST_WARN_MS, 50),
structuredRequestLoggingEnabled: bool(process.env.STRUCTURED_REQUEST_LOGGING_ENABLED, !testMode),
chatLimit: int(process.env.CHAT_LIMIT, 10),
chatHistoryLimit: int(process.env.CHAT_HISTORY_LIMIT, 20),
chatSummaryUpdateEveryMessages: int(process.env.CHAT_SUMMARY_UPDATE_EVERY_MESSAGES, 8),
+80
View File
@@ -236,6 +236,50 @@ export function createRepositories(db, config) {
AND username = ?
`),
deleteChatMemory: db.prepare("DELETE FROM chat_memories WHERE id = ? AND chat_id = ? AND username = ?"),
getPersonaChatState: db.prepare(`
SELECT id,
username,
chat_id,
assistant_persona_id,
user_persona_id,
relationship_notes,
tone,
current_location,
goals,
unresolved_threads,
boundaries,
created_at,
updated_at
FROM persona_chat_state
WHERE chat_id = ?
AND username = ?
`),
upsertPersonaChatState: db.prepare(`
INSERT INTO persona_chat_state (
username,
chat_id,
assistant_persona_id,
user_persona_id,
relationship_notes,
tone,
current_location,
goals,
unresolved_threads,
boundaries
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chat_id) DO UPDATE SET
username = excluded.username,
assistant_persona_id = excluded.assistant_persona_id,
user_persona_id = excluded.user_persona_id,
relationship_notes = excluded.relationship_notes,
tone = excluded.tone,
current_location = excluded.current_location,
goals = excluded.goals,
unresolved_threads = excluded.unresolved_threads,
boundaries = excluded.boundaries,
updated_at = CURRENT_TIMESTAMP
`),
updateChatMessage: db.prepare("UPDATE chat_messages SET content = ? WHERE id = ? AND chat_id = ?"),
updateChatMessageWithRetryState: db.prepare("UPDATE chat_messages SET content = ?, retry_variants = ?, retry_active_index = ?, retry_retries_used = ?, retry_prompt_message_id = ? WHERE id = ? AND chat_id = ?"),
deleteChatMessage: db.prepare("DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"),
@@ -545,6 +589,7 @@ export function createRepositories(db, config) {
updatePersonaVersionsUsername: db.prepare("UPDATE persona_versions SET username = ? WHERE username = ?"),
updatePromptTemplatesUsername: db.prepare("UPDATE prompt_templates SET username = ? WHERE username = ?"),
updateChatMemoriesUsername: db.prepare("UPDATE chat_memories SET username = ? WHERE username = ?"),
updatePersonaChatStateUsername: db.prepare("UPDATE persona_chat_state SET username = ? WHERE username = ?"),
updateMarketFavoritesUsername: db.prepare("UPDATE persona_market_favorites SET username = ? WHERE username = ?"),
updateMarketRatingsUsername: db.prepare("UPDATE persona_market_ratings SET username = ? WHERE username = ?"),
updateMarketReportsUsername: db.prepare("UPDATE persona_market_reports SET reporter_username = ? WHERE reporter_username = ?"),
@@ -558,6 +603,7 @@ export function createRepositories(db, config) {
deleteMarketFavoritesByUser: db.prepare("DELETE FROM persona_market_favorites WHERE username = ?"),
deleteMarketRatingsByUser: db.prepare("DELETE FROM persona_market_ratings WHERE username = ?"),
deleteMarketReportsByUser: db.prepare("DELETE FROM persona_market_reports WHERE reporter_username = ?"),
deletePersonaChatStateByUser: db.prepare("DELETE FROM persona_chat_state WHERE username = ?"),
deleteUser: db.prepare("DELETE FROM users WHERE username = ?"),
deleteMarketPersonaByPersonaId: db.prepare("DELETE FROM persona_market WHERE persona_id = ? AND creator_username = ?"),
incrementMarketUsageCount: db.prepare("UPDATE persona_market SET usage_count = usage_count + 1 WHERE id = ?")
@@ -573,6 +619,7 @@ export function createRepositories(db, config) {
statements.updatePersonaVersionsUsername.run(nextUsername, currentUsername);
statements.updatePromptTemplatesUsername.run(nextUsername, currentUsername);
statements.updateChatMemoriesUsername.run(nextUsername, currentUsername);
statements.updatePersonaChatStateUsername.run(nextUsername, currentUsername);
statements.updateMarketFavoritesUsername.run(nextUsername, currentUsername);
statements.updateMarketRatingsUsername.run(nextUsername, currentUsername);
statements.updateMarketReportsUsername.run(nextUsername, currentUsername);
@@ -586,6 +633,7 @@ export function createRepositories(db, config) {
statements.deletePromptTemplatesByUser.run(username);
statements.deletePersonaVersionsByUser.run(username);
statements.deleteChatMemoriesByUser.run(username);
statements.deletePersonaChatStateByUser.run(username);
statements.deleteChatSessionsByUser.run(username);
statements.deleteUserSettingsByUser.run(username);
statements.deletePersonasByUser.run(username);
@@ -686,6 +734,21 @@ export function createRepositories(db, config) {
if (!fact) continue;
statements.insertChatMemory.run(username, chatId, null, null, fact);
}
const personaState = chat.persona_state || chat.personaChatState;
if (personaState) {
statements.upsertPersonaChatState.run(
username,
chatId,
null,
null,
personaState.relationship_notes ?? personaState.relationshipNotes ?? null,
personaState.tone ?? null,
personaState.current_location ?? personaState.currentLocation ?? null,
personaState.goals ?? null,
personaState.unresolved_threads ?? personaState.unresolvedThreads ?? null,
personaState.boundaries ?? null
);
}
imported.chats += 1;
}
@@ -727,6 +790,22 @@ export function createRepositories(db, config) {
updateChatTitle: (chatId, username, title) => statements.updateChatSessionTitle.run(title, chatId, username),
updateChatScene: (chatId, username, scenarioPrompt, scenarioSummary) => statements.updateChatSessionScene.run(scenarioPrompt, scenarioSummary, chatId, username),
updateChatContextSummary: (chatId, username, summary, messageId) => statements.updateChatContextSummary.run(summary, Number(messageId || 0), chatId, username),
getPersonaChatState: (chatId, username) => statements.getPersonaChatState.get(chatId, username) || null,
updatePersonaChatState(username, chat, state) {
statements.upsertPersonaChatState.run(
username,
chat.id,
chat.assistant_persona_id ?? null,
chat.user_persona_id ?? null,
state.relationship_notes ?? null,
state.tone ?? null,
state.current_location ?? null,
state.goals ?? null,
state.unresolved_threads ?? null,
state.boundaries ?? null
);
return statements.getPersonaChatState.get(chat.id, username);
},
updateChatOrganization(chatId, username, {folderName = null, isPinned = false, archivedAt = null}) {
return statements.updateChatSessionOrganization.run(folderName, isPinned ? 1 : 0, archivedAt, chatId, username);
},
@@ -994,6 +1073,7 @@ export function createRepositories(db, config) {
chats: this.listChats(username).map((chat) => ({
...chat,
memories: this.listChatMemories(chat.id, username),
persona_state: this.getPersonaChatState(chat.id, username),
messages: this.listChatMessages(chat.id).map((message) => ({
...message,
retryVariants: parseJsonArray(message.retry_variants)
+46
View File
@@ -68,6 +68,29 @@ export function initializeSchema(db) {
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_chat_state
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
chat_id INTEGER NOT NULL UNIQUE,
assistant_persona_id INTEGER,
user_persona_id INTEGER,
relationship_notes TEXT,
tone TEXT,
current_location TEXT,
goals TEXT,
unresolved_threads TEXT,
boundaries TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (username) REFERENCES users (username) ON DELETE CASCADE,
FOREIGN KEY (chat_id) REFERENCES chat_sessions (id) ON DELETE CASCADE,
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();
db.prepare(`
CREATE TABLE IF NOT EXISTS personas
(
@@ -275,6 +298,29 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_chat_state
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
chat_id INTEGER NOT NULL UNIQUE,
assistant_persona_id INTEGER,
user_persona_id INTEGER,
relationship_notes TEXT,
tone TEXT,
current_location TEXT,
goals TEXT,
unresolved_threads TEXT,
boundaries TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (username) REFERENCES users (username) ON DELETE CASCADE,
FOREIGN KEY (chat_id) REFERENCES chat_sessions (id) ON DELETE CASCADE,
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();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_versions
(
+39
View File
@@ -39,6 +39,29 @@ const memoryValidator = (body) => {
return {value: {fact}};
};
const personaStateFields = [
"relationship_notes",
"tone",
"current_location",
"goals",
"unresolved_threads",
"boundaries"
];
const normalizeStateText = (value) => String(value ?? "").replace(/\r\n/g, "\n").trim();
const personaStateValidator = (body) => {
const value = {};
for (const field of personaStateFields) {
const normalized = normalizeStateText(body?.[field]);
if (normalized.length > 1200) {
return {error: `${field} must be 1200 characters or fewer`};
}
value[field] = normalized || null;
}
return {value};
};
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}) {
const router = express.Router();
router.use(requireLogin);
@@ -247,6 +270,22 @@ export function createChatsRouter({repositories, chatService, modelService, conf
return res.json({message: "Memory deleted"});
});
router.get("/chats/:id/persona-state", (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
return res.json({state: repositories.getPersonaChatState(chatId, req.session.user)});
});
router.put("/chats/:id/persona-state", validateBody(personaStateValidator), (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
const state = repositories.updatePersonaChatState(req.session.user, chat, req.validatedBody);
repositories.touchChat(chatId, req.session.user);
return res.json({state});
});
router.post("/chat", ...chatRateLimiters, validateBody(sendMessageValidator), async (req, res) => {
try {
const result = await chatService.sendChatMessage({
+13 -73
View File
@@ -7,52 +7,17 @@ import {
buildUserPersonaPrompt,
containsUserVoiceInRoleplayOpener
} from "./personaService.js";
import {
buildContextSummaryPrompt,
buildMemoryPrompt,
buildPersonaStatePrompt,
clampRetryActiveIndex,
formatChatMessage,
formatMessagesForSummary,
retryStylePrompts
} from "./chatMessageHelpers.js";
export function createChatService(repositories, modelService, config) {
const retryStylePrompts = {
shorter: "Rewrite the assistant reply to be shorter and tighter while preserving its meaning.",
direct: "Rewrite the assistant reply to be more direct and less padded.",
emotional: "Rewrite the assistant reply to be more emotionally expressive without changing the core meaning.",
"stay-in-character": "Rewrite the assistant reply to stay more strongly in character and voice.",
dialogue: "Rewrite the assistant reply as dialogue only. Remove narration unless required for clarity."
};
const clampRetryActiveIndex = (index, variants) => {
const numeric = Number(index);
if (!Number.isInteger(numeric) || numeric < 0) return 0;
return Math.min(numeric, Math.max(0, variants.length - 1));
};
const parseRetryVariants = (rawValue, content) => {
if (!rawValue) return [content || ""];
try {
const parsed = JSON.parse(rawValue);
if (Array.isArray(parsed)) {
const normalized = parsed.map((item) => String(item || "").trim()).filter(Boolean);
if (normalized.length) return normalized;
}
} catch {
return [content || ""];
}
return [content || ""];
};
const formatChatMessage = (row) => {
if (!row) return null;
const retryVariants = parseRetryVariants(row.retry_variants, row.content);
const retryActiveIndex = clampRetryActiveIndex(row.retry_active_index, retryVariants);
return {
id: row.id,
role: row.role,
content: row.content,
modelName: row.model_name || null,
retryVariants,
retryActiveIndex,
retryRetriesUsed: Number(row.retry_retries_used || 0),
retryPromptMessageId: row.retry_prompt_message_id ?? null
};
};
const insertChatMessage = (chatId, role, content, retryState = null, modelName = null) => {
const normalizedContent = String(content || "");
const payload = retryState
@@ -87,41 +52,12 @@ export function createChatService(repositories, modelService, config) {
return repositories.getChatMessageByIndex(chatId, safeIndex) || null;
};
const buildMemoryPrompt = (memories) => {
const facts = (memories || [])
.map((memory) => String(memory.fact || "").trim())
.filter(Boolean)
.slice(0, 30);
if (!facts.length) return null;
return [
"LONG-TERM MEMORY:",
"These are durable facts the assistant should remember for this chat/persona context.",
"Use them for continuity, but do not mention them unless relevant.",
...facts.map((fact, index) => `${index + 1}. ${fact}`)
].join("\n");
};
const clampSummary = (value) => {
const normalized = String(value || "").trim();
if (normalized.length <= config.chatSummaryMaxChars) return normalized;
return `${normalized.slice(0, Math.max(0, config.chatSummaryMaxChars - 3)).trimEnd()}...`;
};
const buildContextSummaryPrompt = (summary) => {
if (!summary) return null;
return [
"CONVERSATION SUMMARY:",
"This is compressed background context from earlier messages in this chat.",
"Use it for continuity. Recent raw messages below are more authoritative.",
summary
].join("\n");
};
const formatMessagesForSummary = (messages) => messages.map((message) => {
const speaker = message.role === "bot" ? "Assistant" : message.role === "user" ? "User" : message.role;
return `${speaker}: ${String(message.content || "").trim()}`;
}).join("\n\n");
const maybeUpdateContextSummary = async ({user, chatId, selectedModel}) => {
if (config.chatSummaryUpdateEveryMessages <= 0) return;
const session = repositories.getChat(chatId, user);
@@ -233,6 +169,7 @@ export function createChatService(repositories, modelService, config) {
const activeUserPersona = repositories.getUserPersonaForChat(chatId, user) || repositories.getActiveUserPersona(user);
const promptMessage = repositories.getPreviousUserMessage(chatId, targetMessage.id);
const memoryPrompt = buildMemoryPrompt(repositories.listChatMemories(chatId, user));
const personaStatePrompt = buildPersonaStatePrompt(repositories.getPersonaChatState(chatId, user));
if (!promptMessage) {
if (!activePersona || !session.scenario_summary) {
@@ -269,6 +206,7 @@ export function createChatService(repositories, modelService, config) {
const contextSummaryPrompt = buildContextSummaryPrompt(session.context_summary);
if (contextSummaryPrompt) messagesPayload.push({role: "system", content: contextSummaryPrompt});
if (memoryPrompt) messagesPayload.push({role: "system", content: memoryPrompt});
if (personaStatePrompt) messagesPayload.push({role: "system", content: personaStatePrompt});
messagesPayload.push(...conversation.map((message) => ({
role: message.role === "bot" ? "assistant" : "user",
content: message.content
@@ -318,6 +256,8 @@ export function createChatService(repositories, modelService, config) {
if (contextSummaryPrompt) messagesPayload.push({role: "system", content: contextSummaryPrompt});
const memoryPrompt = buildMemoryPrompt(repositories.listChatMemories(chatId, user));
if (memoryPrompt) messagesPayload.push({role: "system", content: memoryPrompt});
const personaStatePrompt = buildPersonaStatePrompt(repositories.getPersonaChatState(chatId, user));
if (personaStatePrompt) messagesPayload.push({role: "system", content: personaStatePrompt});
messagesPayload.push(...conversation.map((entry) => ({
role: entry.role === "bot" ? "assistant" : "user",
content: entry.content
+19 -1
View File
@@ -104,6 +104,22 @@ function validateMemory(memory, chatIndex, memoryIndex, errors) {
return {fact};
}
function validatePersonaState(state, chatIndex, errors) {
if (state == null) return null;
if (!state || typeof state !== "object" || Array.isArray(state)) {
errors.push(`Chat ${chatIndex + 1}, persona_state must be an object.`);
return null;
}
return {
relationship_notes: optionalText(state.relationship_notes || state.relationshipNotes),
tone: optionalText(state.tone),
current_location: optionalText(state.current_location || state.currentLocation),
goals: optionalText(state.goals),
unresolved_threads: optionalText(state.unresolved_threads || state.unresolvedThreads),
boundaries: optionalText(state.boundaries)
};
}
function validateChat(chat, index, errors) {
if (!chat || typeof chat !== "object" || Array.isArray(chat)) {
errors.push(`Chat ${index + 1} must be an object.`);
@@ -112,6 +128,7 @@ function validateChat(chat, index, errors) {
const title = normalizeText(chat.stored_title || chat.title || "Imported chat") || "Imported chat";
const messages = asArray(chat.messages).map((message, messageIndex) => validateMessage(message, index, messageIndex, errors)).filter(Boolean);
const memories = asArray(chat.memories).map((memory, memoryIndex) => validateMemory(memory, index, memoryIndex, errors)).filter(Boolean);
const personaState = validatePersonaState(chat.persona_state || chat.personaChatState, index, errors);
return {
title,
stored_title: title,
@@ -125,7 +142,8 @@ function validateChat(chat, index, errors) {
? Number(chat.context_summary_message_id || chat.contextSummaryMessageId)
: 0,
messages,
memories
memories,
persona_state: personaState
};
}