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
+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
};
}