Files
KrisHD_AI/server.js
T

1333 lines
41 KiB
JavaScript

import express from "express";
import session from "express-session";
import bcrypt from "bcrypt";
import fs from "fs";
import path from "path";
import bodyParser from "body-parser";
import Database from "better-sqlite3";
import FileStoreFactory from "session-file-store";
import {exec} from "child_process";
import {performance} from "perf_hooks";
let last = performance.now();
setInterval(() => {
const now = performance.now();
const drift = now - last - 1000;
last = now;
if (drift > 50) {
console.log("EVENT LOOP LAG", Math.round(drift), "ms", new Date().toISOString());
}
}, 1000);
const modelState = new Map();
const app = express();
const PORT = 3000;
const DB_PATH = "./data/app.db";
const SESSIONS_DIR = path.resolve("sessions");
const UNLOAD_AFTER_MS = 30 * 1000;
let modelsCache = null;
let modelsCacheAt = 0;
const MODELS_TTL_MS = 5 * 60 * 1000;
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const ms = Date.now() - start;
if (ms > 50) console.log("SLOW REQ", ms + "ms", req.method, req.url);
});
next();
});
if (!fs.existsSync("./data")) {
fs.mkdirSync("./data", {recursive: true});
}
if (!fs.existsSync(SESSIONS_DIR)) {
fs.mkdirSync(SESSIONS_DIR, {recursive: true});
}
function scheduleModelUnload(model) {
if (!modelState.has(model)) {
modelState.set(model, {timer: null, activeRequests: 0});
}
const state = modelState.get(model);
if (state.timer) {
clearTimeout(state.timer);
}
state.timer = setTimeout(() => {
if (state.activeRequests > 0) return;
console.log(`Unloading model: ${model}`);
exec(`ollama stop ${model}`, (err) => {
if (err) {
console.error(`Failed to unload ${model}:`, err.message);
} else {
console.log(`Model unloaded: ${model}`);
modelState.delete(model);
}
});
}, UNLOAD_AFTER_MS);
}
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.prepare(`
CREATE TABLE IF NOT EXISTS users
(
username
TEXT
PRIMARY
KEY,
password
TEXT
NOT
NULL
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS chats
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
username
TEXT
NOT
NULL,
role
TEXT
NOT
NULL,
content
TEXT
NOT
NULL,
created_at
DATETIME
DEFAULT
CURRENT_TIMESTAMP
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS chat_sessions
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
username
TEXT
NOT
NULL,
title
TEXT
NOT
NULL,
created_at
DATETIME
DEFAULT
CURRENT_TIMESTAMP,
updated_at
DATETIME
DEFAULT
CURRENT_TIMESTAMP
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS chat_messages
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
chat_id
INTEGER
NOT
NULL,
role
TEXT
NOT
NULL,
content
TEXT
NOT
NULL,
created_at
DATETIME
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
chat_id
) REFERENCES chat_sessions
(
id
) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS personas
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
username
TEXT
NOT
NULL,
name
TEXT
NOT
NULL,
pronouns
TEXT,
appearance
TEXT,
background
TEXT,
details
TEXT,
example_dialogues
TEXT,
persona_type
TEXT
DEFAULT
'assistant',
source_market_id
INTEGER,
source_creator_username
TEXT,
created_at
DATETIME
DEFAULT
CURRENT_TIMESTAMP,
updated_at
DATETIME
DEFAULT
CURRENT_TIMESTAMP
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS user_settings
(
username
TEXT
PRIMARY
KEY,
active_persona_id
INTEGER,
active_user_persona_id
INTEGER,
FOREIGN
KEY
(
active_persona_id
) REFERENCES personas
(
id
) ON DELETE SET NULL,
FOREIGN
KEY
(
active_user_persona_id
) REFERENCES personas
(
id
) ON DELETE SET NULL
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_market
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
persona_id
INTEGER,
creator_username
TEXT
NOT
NULL,
name
TEXT
NOT
NULL,
pronouns
TEXT,
appearance
TEXT,
background
TEXT,
details
TEXT,
example_dialogues
TEXT,
persona_type
TEXT
DEFAULT
'assistant',
usage_count
INTEGER
DEFAULT
0,
created_at
DATETIME
DEFAULT
CURRENT_TIMESTAMP,
updated_at
DATETIME
DEFAULT
CURRENT_TIMESTAMP,
UNIQUE
(
persona_id,
creator_username
)
)
`).run();
const insertUserStmt = db.prepare("INSERT INTO users (username, password) VALUES (?, ?)");
const getUserStmt = db.prepare("SELECT username, password FROM users WHERE username = ?");
const updateUsernameStmt = db.prepare("UPDATE users SET username = ? WHERE username = ?");
const updatePasswordStmt = db.prepare("UPDATE users SET password = ? WHERE username = ?");
const getLegacyChatsStmt = db.prepare(`
SELECT role, content
FROM chats
WHERE username = ?
ORDER BY datetime(created_at)
`);
const insertChatSessionStmt = db.prepare(
"INSERT INTO chat_sessions (username, title) VALUES (?, ?)"
);
const listChatSessionsStmt = db.prepare(`
SELECT id, title, created_at, updated_at
FROM chat_sessions
WHERE username = ?
ORDER BY datetime(updated_at) DESC
`);
const countChatSessionsStmt = db.prepare(`
SELECT COUNT(*) as count
FROM chat_sessions
WHERE username = ?
`);
const getChatSessionStmt = db.prepare(
"SELECT id, title FROM chat_sessions WHERE id = ? AND username = ?"
);
const updateChatSessionTitleStmt = db.prepare(
"UPDATE chat_sessions SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"
);
const touchChatSessionStmt = db.prepare(
"UPDATE chat_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"
);
const deleteChatSessionStmt = db.prepare(
"DELETE FROM chat_sessions WHERE id = ? AND username = ?"
);
const insertChatMessageStmt = db.prepare(
"INSERT INTO chat_messages (chat_id, role, content) VALUES (?, ?, ?)"
);
const getChatMessagesStmt = db.prepare(`
SELECT role, content
FROM chat_messages
WHERE chat_id = ?
ORDER BY datetime(created_at)
`
)
;
const getRecentChatMessagesStmt = db.prepare(`
SELECT role, content
FROM chat_messages
WHERE chat_id = ?
ORDER BY datetime(created_at) DESC
LIMIT 20
`
)
;
const deleteChatMessagesStmt = db.prepare(
"DELETE FROM chat_messages WHERE chat_id = ?"
);
const listPersonasByTypeStmt = db.prepare(`
SELECT id,
name,
pronouns,
appearance,
background,
details,
example_dialogues,
persona_type,
source_market_id,
source_creator_username,
created_at,
updated_at
FROM personas
WHERE username = ?
AND persona_type = ?
ORDER BY datetime(updated_at) DESC
`);
const getPersonaStmt = db.prepare(`
SELECT id, name, pronouns, appearance, background, details, example_dialogues, persona_type
FROM personas
WHERE id = ?
AND username = ?
`);
const getPersonaForPublishStmt = db.prepare(`
SELECT id,
name,
pronouns,
appearance,
background,
details,
example_dialogues,
persona_type,
source_market_id,
source_creator_username
FROM personas
WHERE id = ?
AND username = ?
`);
const insertPersonaStmt = db.prepare(
"INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
);
const insertMarketPersonaStmt = db.prepare(
"INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type, source_market_id, source_creator_username) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
const getPersonaBySourceMarketStmt = db.prepare(`
SELECT id
FROM personas
WHERE username = ?
AND source_market_id = ?
`);
const updatePersonaStmt = db.prepare(`
UPDATE personas
SET name = ?,
pronouns = ?,
appearance = ?,
background = ?,
details = ?,
example_dialogues = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND username = ?
`);
const deletePersonaStmt = db.prepare(
"DELETE FROM personas WHERE id = ? AND username = ?"
);
const getActivePersonaIdStmt = db.prepare(
"SELECT active_persona_id, active_user_persona_id FROM user_settings WHERE username = ?"
);
const setActivePersonaStmt = db.prepare(`
INSERT INTO user_settings (username, active_persona_id)
VALUES (?, ?)
ON CONFLICT(username) DO UPDATE SET active_persona_id = excluded.active_persona_id
`);
const setActiveUserPersonaStmt = db.prepare(`
INSERT INTO user_settings (username, active_user_persona_id)
VALUES (?, ?)
ON CONFLICT(username) DO UPDATE SET active_user_persona_id = excluded.active_user_persona_id
`);
const getActivePersonaStmt = db.prepare(`
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
FROM user_settings us
JOIN personas p ON p.id = us.active_persona_id
WHERE us.username = ?
`);
const getActiveUserPersonaStmt = db.prepare(`
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
FROM user_settings us
JOIN personas p ON p.id = us.active_user_persona_id
WHERE us.username = ?
`);
const listMarketPersonasStmt = db.prepare(`
SELECT id,
persona_id,
creator_username,
name,
pronouns,
appearance,
background,
details,
example_dialogues,
persona_type,
usage_count,
created_at,
updated_at
FROM persona_market
ORDER BY datetime(updated_at) DESC
`);
const getMarketPersonaStmt = db.prepare(`
SELECT id,
persona_id,
creator_username,
name,
pronouns,
appearance,
background,
details,
example_dialogues,
persona_type,
usage_count
FROM persona_market
WHERE id = ?
`);
const getMarketPersonaByPersonaIdStmt = db.prepare(`
SELECT id,
persona_id,
creator_username,
name,
pronouns,
appearance,
background,
details,
example_dialogues,
persona_type
FROM persona_market
WHERE persona_id = ?
AND creator_username = ?
`);
const upsertMarketPersonaStmt = db.prepare(`
INSERT INTO persona_market (persona_id, creator_username, name, pronouns, appearance, background, details,
example_dialogues, persona_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(persona_id, creator_username) DO UPDATE SET name = excluded.name,
pronouns = excluded.pronouns,
appearance = excluded.appearance,
background = excluded.background,
details = excluded.details,
example_dialogues = excluded.example_dialogues,
persona_type = excluded.persona_type,
updated_at = CURRENT_TIMESTAMP
`);
const listPublishedPersonaIdsStmt = db.prepare(`
SELECT persona_id
FROM persona_market
WHERE creator_username = ?
`);
const getChatMessageCountForUserStmt = db.prepare(`
SELECT COUNT(*) as count
FROM chat_messages cm
JOIN chat_sessions cs ON cs.id = cm.chat_id
WHERE cs.username = ?
`);
const getPersonaCountForUserStmt = db.prepare(`
SELECT COUNT(*) as count
FROM personas
WHERE username = ?
`);
const getPublishedPersonaCountForUserStmt = db.prepare(`
SELECT COUNT(*) as count
FROM persona_market
WHERE creator_username = ?
`);
const updateLegacyChatsUsernameStmt = db.prepare(`
UPDATE chats
SET username = ?
WHERE username = ?
`);
const updateChatSessionsUsernameStmt = db.prepare(`
UPDATE chat_sessions
SET username = ?
WHERE username = ?
`);
const updatePersonasUsernameStmt = db.prepare(`
UPDATE personas
SET username = ?
WHERE username = ?
`);
const updatePersonasSourceCreatorStmt = db.prepare(`
UPDATE personas
SET source_creator_username = ?
WHERE source_creator_username = ?
`);
const updateUserSettingsUsernameStmt = db.prepare(`
UPDATE user_settings
SET username = ?
WHERE username = ?
`);
const updatePersonaMarketCreatorStmt = db.prepare(`
UPDATE persona_market
SET creator_username = ?
WHERE creator_username = ?
`);
const renameUserTransaction = db.transaction((currentUsername, nextUsername) => {
updateUsernameStmt.run(nextUsername, currentUsername);
updateLegacyChatsUsernameStmt.run(nextUsername, currentUsername);
updateChatSessionsUsernameStmt.run(nextUsername, currentUsername);
updatePersonasUsernameStmt.run(nextUsername, currentUsername);
updatePersonasSourceCreatorStmt.run(nextUsername, currentUsername);
updateUserSettingsUsernameStmt.run(nextUsername, currentUsername);
updatePersonaMarketCreatorStmt.run(nextUsername, currentUsername);
});
const deleteMarketPersonaByPersonaIdStmt = db.prepare(`
DELETE FROM persona_market
WHERE persona_id = ?
AND creator_username = ?
`);
const incrementMarketUsageCountStmt = db.prepare(`
UPDATE persona_market
SET usage_count = usage_count + 1
WHERE id = ?
`);
const migrationNeeded = db.prepare(
"SELECT COUNT(*) as count FROM chat_messages"
).get();
const legacyChatsExist = db.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chats'"
).get();
if (legacyChatsExist && migrationNeeded.count === 0) {
const legacyUsers = db.prepare(
"SELECT DISTINCT username FROM chats"
).all();
legacyUsers.forEach(({username}) => {
const sessionId = insertChatSessionStmt.run(username, "Imported chat").lastInsertRowid;
const legacyMessages = getLegacyChatsStmt.all(username);
legacyMessages.forEach(msg => {
insertChatMessageStmt.run(sessionId, msg.role, msg.content);
});
});
}
const personaColumns = db.prepare("PRAGMA table_info(personas)").all();
const hasCustomFields = personaColumns.some(column => column.name === "custom_fields");
if (!hasCustomFields) {
db.prepare("ALTER TABLE personas ADD COLUMN custom_fields TEXT").run();
}
const hasPersonaType = personaColumns.some(column => column.name === "persona_type");
if (!hasPersonaType) {
db.prepare("ALTER TABLE personas ADD COLUMN persona_type TEXT DEFAULT 'assistant'").run();
}
const hasSourceMarketId = personaColumns.some(column => column.name === "source_market_id");
if (!hasSourceMarketId) {
db.prepare("ALTER TABLE personas ADD COLUMN source_market_id INTEGER").run();
}
const hasSourceCreatorUsername = personaColumns.some(column => column.name === "source_creator_username");
if (!hasSourceCreatorUsername) {
db.prepare("ALTER TABLE personas ADD COLUMN source_creator_username TEXT").run();
}
const hasExampleDialogues = personaColumns.some(column => column.name === "example_dialogues");
if (!hasExampleDialogues) {
db.prepare("ALTER TABLE personas ADD COLUMN example_dialogues TEXT").run();
}
const userSettingsColumns = db.prepare("PRAGMA table_info(user_settings)").all();
const hasActiveUserPersona = userSettingsColumns.some(column => column.name === "active_user_persona_id");
if (!hasActiveUserPersona) {
db.prepare("ALTER TABLE user_settings ADD COLUMN active_user_persona_id INTEGER").run();
}
const marketColumns = db.prepare("PRAGMA table_info(persona_market)").all();
const hasPersonaIdColumn = marketColumns.some(column => column.name === "persona_id");
if (!hasPersonaIdColumn) {
db.prepare("ALTER TABLE persona_market ADD COLUMN persona_id INTEGER").run();
}
const hasUsageCount = marketColumns.some(column => column.name === "usage_count");
if (!hasUsageCount) {
db.prepare("ALTER TABLE persona_market ADD COLUMN usage_count INTEGER DEFAULT 0").run();
}
const hasMarketExampleDialogues = marketColumns.some(column => column.name === "example_dialogues");
if (!hasMarketExampleDialogues) {
db.prepare("ALTER TABLE persona_market ADD COLUMN example_dialogues TEXT").run();
}
db.prepare("UPDATE persona_market SET usage_count = 0 WHERE usage_count IS NULL").run();
const FileStore = FileStoreFactory(session);
app.use(bodyParser.json());
app.use(express.static("public"));
app.use(
session({
store: new FileStore({
path: SESSIONS_DIR,
reapInterval: 3600
}),
secret: "-secret-key-here-",
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
httpOnly: true,
maxAge: 7 * 24 * 60 * 60 * 1000
}
})
);
app.post("/register", async (req, res) => {
const {username, password} = req.body;
const existingUser = getUserStmt.get(username);
if (existingUser) return res.status(400).json({error: "User already exists"});
const hashed = await bcrypt.hash(password, 10);
insertUserStmt.run(username, hashed);
res.json({message: "Registration successful"});
});
app.post("/login", async (req, res) => {
const {username, password} = req.body;
const user = getUserStmt.get(username);
if (!user) return res.status(400).json({error: "User not found"});
const valid = await bcrypt.compare(password, user.password);
if (!valid) return res.status(403).json({error: "Invalid password"});
req.session.user = username;
res.json({message: "Login successful"});
});
app.post("/logout", (req, res) => {
req.session.destroy(() => res.json({message: "Logged out"}));
});
const requireLogin = (req, res, next) => {
if (!req.session.user) return res.status(403).json({error: "Not logged in"});
next();
};
app.get("/settings/profile", requireLogin, (req, res) => {
const user = getUserStmt.get(req.session.user);
if (!user) return res.status(404).json({error: "User not found"});
res.json({username: user.username});
});
app.put("/settings/username", requireLogin, async (req, res) => {
const currentUsername = req.session.user;
const nextUsername = (req.body?.username || "").trim();
const password = req.body?.password || "";
if (!nextUsername) {
return res.status(400).json({error: "New username is required"});
}
if (nextUsername.length < 3) {
return res.status(400).json({error: "Username must be at least 3 characters"});
}
if (nextUsername === currentUsername) {
return res.status(400).json({error: "That is already your username"});
}
const currentUser = getUserStmt.get(currentUsername);
if (!currentUser) {
return res.status(404).json({error: "User not found"});
}
const valid = await bcrypt.compare(password, currentUser.password);
if (!valid) {
return res.status(403).json({error: "Current password is incorrect"});
}
if (getUserStmt.get(nextUsername)) {
return res.status(400).json({error: "Username already exists"});
}
renameUserTransaction(currentUsername, nextUsername);
req.session.user = nextUsername;
res.json({username: nextUsername});
});
app.put("/settings/password", requireLogin, async (req, res) => {
const username = req.session.user;
const currentPassword = req.body?.currentPassword || "";
const nextPassword = req.body?.newPassword || "";
if (!currentPassword || !nextPassword) {
return res.status(400).json({error: "Current and new password are required"});
}
if (nextPassword.length < 6) {
return res.status(400).json({error: "New password must be at least 6 characters"});
}
const user = getUserStmt.get(username);
if (!user) {
return res.status(404).json({error: "User not found"});
}
const valid = await bcrypt.compare(currentPassword, user.password);
if (!valid) {
return res.status(403).json({error: "Current password is incorrect"});
}
const hashed = await bcrypt.hash(nextPassword, 10);
updatePasswordStmt.run(hashed, username);
res.json({message: "Password updated"});
});
const normalizePersonaField = (value) => {
const trimmed = (value || "").trim();
return trimmed ? trimmed : null;
};
const PERSONA_FIELD_LIMITS = {
name: 200,
pronouns: 40,
appearance: 9000,
background: 9000,
details: 9000,
exampleDialogues: 12000
};
const blockedPersonaPatterns = [
/\b(child|children|kid|kids|minor|underage|under-aged|teenager|young girl|young boy|loli|shota)\b/i,
/\b(nazi|hitler|third reich|kkk|ku klux klan|white power|nigger|nigga)\b/i,
/\b(rape|rapist|sexual assault|molest|molestation|incest|bestiality|zoophilia|necrophilia)\b/i,
/\b(self-harm|self harm|suicide fetish)\b/i
];
const validatePersonaPayload = (payload) => {
const sanitized = {
name: (payload?.name || "").trim(),
pronouns: normalizePersonaField(payload?.pronouns),
appearance: normalizePersonaField(payload?.appearance),
background: normalizePersonaField(payload?.background),
details: normalizePersonaField(payload?.details),
exampleDialogues: payload?.personaType === "assistant" ? normalizePersonaField(payload?.exampleDialogues) : null
};
if (!sanitized.name) {
return {error: "Name is required"};
}
for (const [key, limit] of Object.entries(PERSONA_FIELD_LIMITS)) {
const value = sanitized[key];
if (value && value.length > limit) {
return {error: `${key.charAt(0).toUpperCase() + key.slice(1)} is too long`};
}
}
const combinedText = [
sanitized.name,
sanitized.pronouns,
sanitized.appearance,
sanitized.background,
sanitized.details,
sanitized.exampleDialogues
].filter(Boolean).join("\n");
if (blockedPersonaPatterns.some((pattern) => pattern.test(combinedText))) {
return {error: "This persona contains disallowed content. Remove sexual content involving minors, extreme sexual violence, or hateful/extremist material."};
}
return {sanitized};
};
const buildPersonaPrompt = (persona) => {
const lines = [
"SYSTEM ROLE:",
"You are the AI assistant in this conversation.",
"You must speak, think, and respond AS the persona described below.",
"Do NOT describe or roleplay as the user.",
"Do NOT switch roles or perspectives.",
"Always respond in-character, using first-person language where appropriate.",
"",
"ASSISTANT PERSONA:"
];
if (persona.name) lines.push(`Name: ${persona.name}`);
if (persona.pronouns) lines.push(`Pronouns: ${persona.pronouns}`);
if (persona.appearance) lines.push(`Appearance: ${persona.appearance}`);
if (persona.background) lines.push(`Background: ${persona.background}`);
if (persona.details) lines.push(`Additional Traits: ${persona.details}`);
if (persona.example_dialogues) {
lines.push("", "EXAMPLE DIALOGUES:");
lines.push("Use these examples to mirror tone, cadence, and phrasing without rigidly repeating them.");
lines.push(persona.example_dialogues);
}
return lines.join("\n");
};
const buildUserPersonaPrompt = (persona) => {
const lines = [
"USER CONTEXT:",
"The human user is roleplaying as the persona below.",
"This information is for context only.",
"Do NOT speak as this character.",
"Do NOT narrate their thoughts unless they explicitly do so.",
"Respond TO this persona, not AS them.",
"",
"USER PERSONA:"
];
if (persona.name) lines.push(`Name: ${persona.name}`);
if (persona.pronouns) lines.push(`Pronouns: ${persona.pronouns}`);
if (persona.appearance) lines.push(`Appearance: ${persona.appearance}`);
if (persona.background) lines.push(`Background: ${persona.background}`);
if (persona.details) lines.push(`Additional Traits: ${persona.details}`);
return lines.join("\n");
};
app.get("/models", async (req, res) => {
try {
if (modelsCache && (Date.now() - modelsCacheAt) < MODELS_TTL_MS) {
return res.json(modelsCache);
}
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 4000);
const response = await fetch("https://ai.krishd.ch/api/tags", {signal: controller.signal});
clearTimeout(t);
if (!response.ok) return res.status(500).json({error: "Failed to load models"});
modelsCache = await response.json();
modelsCacheAt = Date.now();
res.json(modelsCache);
} catch (e) {
res.status(500).json({error: "Failed to load models"});
}
});
app.get("/chats", requireLogin, (req, res) => {
const user = req.session.user;
const chats = listChatSessionsStmt.all(user);
res.json({chats});
});
app.post("/chats", requireLogin, (req, res) => {
const user = req.session.user;
const sessionCount = countChatSessionsStmt.get(user).count;
if (sessionCount >= 10) {
return res.status(400).json({error: "Maximum of 10 chats reached. Please delete an old chat to create a new one."});
}
const title = (req.body?.title || "New chat").trim() || "New chat";
const chatId = insertChatSessionStmt.run(user, title).lastInsertRowid;
res.json({chat: {id: chatId, title}});
});
app.put("/chats/:id", requireLogin, (req, res) => {
const user = req.session.user;
const chatId = Number(req.params.id);
const title = (req.body?.title || "").trim();
if (!title) {
return res.status(400).json({error: "Title is required"});
}
const result = updateChatSessionTitleStmt.run(title, chatId, user);
if (result.changes === 0) {
return res.status(404).json({error: "Chat not found"});
}
res.json({chat: {id: chatId, title}});
});
app.delete("/chats/:id", requireLogin, (req, res) => {
const user = req.session.user;
const chatId = Number(req.params.id);
const result = deleteChatSessionStmt.run(chatId, user);
if (result.changes === 0) {
return res.status(404).json({error: "Chat not found"});
}
res.json({message: "Chat deleted"});
});
app.get("/chats/:id/messages", requireLogin, (req, res) => {
const user = req.session.user;
const chatId = Number(req.params.id);
const session = getChatSessionStmt.get(chatId, user);
if (!session) {
return res.status(404).json({error: "Chat not found"});
}
const messages = getChatMessagesStmt.all(chatId);
res.json({messages});
});
app.post("/chats/:id/clear", requireLogin, (req, res) => {
const user = req.session.user;
const chatId = Number(req.params.id);
const session = getChatSessionStmt.get(chatId, user);
if (!session) {
return res.status(404).json({error: "Chat not found"});
}
deleteChatMessagesStmt.run(chatId);
touchChatSessionStmt.run(chatId, user);
res.json({message: "Chat cleared"});
});
app.get("/personas", requireLogin, (req, res) => {
const user = req.session.user;
const assistantPersonas = listPersonasByTypeStmt.all(user, "assistant");
const userPersonas = listPersonasByTypeStmt.all(user, "user");
const activePersonas = getActivePersonaIdStmt.get(user) || {};
const publishedPersonaIds = listPublishedPersonaIdsStmt.all(user).map(row => row.persona_id);
res.json({
assistantPersonas,
userPersonas,
activePersonaId: activePersonas.active_persona_id ?? null,
activeUserPersonaId: activePersonas.active_user_persona_id ?? null,
publishedPersonaIds
});
});
app.post("/personas", requireLogin, (req, res) => {
const user = req.session.user;
const personaType = (req.body?.personaType || "assistant").trim();
if (!["assistant", "user"].includes(personaType)) {
return res.status(400).json({error: "Invalid persona type"});
}
const validation = validatePersonaPayload(req.body);
if (validation.error) {
return res.status(400).json({error: validation.error});
}
const {name, pronouns, appearance, background, details, exampleDialogues} = validation.sanitized;
const personaId = insertPersonaStmt.run(
user,
name,
pronouns,
appearance,
background,
details,
exampleDialogues,
personaType
).lastInsertRowid;
const persona = getPersonaStmt.get(personaId, user);
res.json({persona});
});
app.put("/personas/:id", requireLogin, (req, res) => {
const user = req.session.user;
const personaId = Number(req.params.id);
const validation = validatePersonaPayload(req.body);
if (validation.error) {
return res.status(400).json({error: validation.error});
}
const {name, pronouns, appearance, background, details, exampleDialogues} = validation.sanitized;
const result = updatePersonaStmt.run(
name,
pronouns,
appearance,
background,
details,
exampleDialogues,
personaId,
user
);
if (result.changes === 0) {
return res.status(404).json({error: "Persona not found"});
}
const persona = getPersonaStmt.get(personaId, user);
res.json({persona});
});
app.delete("/personas/:id", requireLogin, (req, res) => {
const user = req.session.user;
const personaId = Number(req.params.id);
const activePersonas = getActivePersonaIdStmt.get(user) || {};
const result = deletePersonaStmt.run(personaId, user);
if (result.changes === 0) {
return res.status(404).json({error: "Persona not found"});
}
if (activePersonas.active_persona_id === personaId) {
setActivePersonaStmt.run(user, null);
}
if (activePersonas.active_user_persona_id === personaId) {
setActiveUserPersonaStmt.run(user, null);
}
deleteMarketPersonaByPersonaIdStmt.run(personaId, user);
res.json({message: "Persona deleted"});
});
app.post("/personas/:id/equip", requireLogin, (req, res) => {
const user = req.session.user;
const personaId = Number(req.params.id);
const persona = getPersonaStmt.get(personaId, user);
if (!persona || persona.persona_type !== "assistant") {
return res.status(404).json({error: "Persona not found"});
}
setActivePersonaStmt.run(user, personaId);
res.json({activePersonaId: personaId});
});
app.post("/personas/:id/equip-user", requireLogin, (req, res) => {
const user = req.session.user;
const personaId = Number(req.params.id);
const persona = getPersonaStmt.get(personaId, user);
if (!persona || persona.persona_type !== "user") {
return res.status(404).json({error: "Persona not found"});
}
setActiveUserPersonaStmt.run(user, personaId);
res.json({activeUserPersonaId: personaId});
});
app.post("/personas/clear", requireLogin, (req, res) => {
const user = req.session.user;
setActivePersonaStmt.run(user, null);
res.json({activePersonaId: null});
});
app.post("/personas/user/clear", requireLogin, (req, res) => {
const user = req.session.user;
setActiveUserPersonaStmt.run(user, null);
res.json({activeUserPersonaId: null});
});
app.get("/personas/market", requireLogin, (req, res) => {
const personas = listMarketPersonasStmt.all();
res.json({personas});
});
app.get("/dashboard/summary", requireLogin, (req, res) => {
const user = req.session.user;
const chats = countChatSessionsStmt.get(user).count;
const messages = getChatMessageCountForUserStmt.get(user).count;
const personas = getPersonaCountForUserStmt.get(user).count;
const published = getPublishedPersonaCountForUserStmt.get(user).count;
const marketPersonas = listMarketPersonasStmt.all().length;
res.json({
username: user,
stats: {
chats,
messages,
personas,
published,
marketPersonas
}
});
});
app.post("/personas/:id/publish", requireLogin, (req, res) => {
const user = req.session.user;
const personaId = Number(req.params.id);
const persona = getPersonaForPublishStmt.get(personaId, user);
if (!persona) {
return res.status(404).json({error: "Persona not found"});
}
if (persona.source_market_id) {
return res.status(403).json({error: "Collected personas cannot be published."});
}
upsertMarketPersonaStmt.run(
personaId,
user,
persona.name,
persona.pronouns,
persona.appearance,
persona.background,
persona.details,
persona.example_dialogues,
persona.persona_type
);
const marketPersona = getMarketPersonaByPersonaIdStmt.get(personaId, user);
res.json({persona: marketPersona});
});
app.post("/personas/:id/unpublish", requireLogin, (req, res) => {
const user = req.session.user;
const personaId = Number(req.params.id);
const persona = getPersonaForPublishStmt.get(personaId, user);
if (!persona) {
return res.status(404).json({error: "Persona not found"});
}
if (persona.source_market_id) {
return res.status(403).json({error: "Collected personas cannot be unpublished."});
}
const result = deleteMarketPersonaByPersonaIdStmt.run(personaId, user);
if (result.changes === 0) {
return res.status(404).json({error: "Persona is not published."});
}
res.json({message: "Persona unpublished"});
});
app.post("/personas/market/:id/collect", requireLogin, (req, res) => {
const user = req.session.user;
const marketId = Number(req.params.id);
const marketPersona = getMarketPersonaStmt.get(marketId);
if (!marketPersona) {
return res.status(404).json({error: "Market persona not found"});
}
if (marketPersona.creator_username === user) {
return res.status(400).json({error: "You already own this persona."});
}
const existingPersona = getPersonaBySourceMarketStmt.get(user, marketId);
if (existingPersona) {
return res.status(400).json({error: "You already collected this persona."});
}
const personaId = insertMarketPersonaStmt.run(
user,
marketPersona.name,
marketPersona.pronouns,
marketPersona.appearance,
marketPersona.background,
marketPersona.details,
marketPersona.example_dialogues,
marketPersona.persona_type,
marketId,
marketPersona.creator_username
).lastInsertRowid;
incrementMarketUsageCountStmt.run(marketId);
let activePersonaId = null;
let activeUserPersonaId = null;
if (req.body?.equip) {
if (marketPersona.persona_type === "user") {
setActiveUserPersonaStmt.run(user, personaId);
activeUserPersonaId = personaId;
} else {
setActivePersonaStmt.run(user, personaId);
activePersonaId = personaId;
}
}
const persona = getPersonaStmt.get(personaId, user);
res.json({persona, activePersonaId, activeUserPersonaId});
});
app.post("/chat", requireLogin, async (req, res) => {
const user = req.session.user;
const {message, model, chatId} = req.body;
const selectedModel = model || "mistral:latest";
const targetChatId = Number(chatId);
if (!targetChatId) {
return res.status(400).json({error: "chatId is required"});
}
const session = getChatSessionStmt.get(targetChatId, user);
if (!session) {
return res.status(404).json({error: "Chat not found"});
}
if (!modelState.has(selectedModel)) {
modelState.set(selectedModel, {activeRequests: 0, timer: null});
}
const state = modelState.get(selectedModel);
state.activeRequests++;
try {
const activePersona = getActivePersonaStmt.get(user);
const activeUserPersona = getActiveUserPersonaStmt.get(user);
const history = getRecentChatMessagesStmt.all(targetChatId);
const conversation = [...history].reverse();
conversation.push({role: "user", content: message});
insertChatMessageStmt.run(targetChatId, "user", message);
touchChatSessionStmt.run(targetChatId, user);
const messagesPayload = [];
if (activePersona) {
messagesPayload.push({
role: "system",
content: buildPersonaPrompt(activePersona)
});
}
if (activeUserPersona) {
messagesPayload.push({
role: "system",
content: buildUserPersonaPrompt(activeUserPersona)
});
}
messagesPayload.push(
...conversation.map(m => ({
role: m.role === "bot" ? "assistant" : "user",
content: m.content
}))
);
const response = await fetch("https://ai.krishd.ch/api/chat", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
model: selectedModel,
messages: messagesPayload,
stream: true
})
});
if (!response.ok) {
throw new Error(`AI HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let fullReply = "";
while (true) {
const {value, done} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
let lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
const json = JSON.parse(line);
if (json.done) {
break;
}
if (json.message?.content) {
fullReply += json.message.content;
}
}
}
insertChatMessageStmt.run(targetChatId, "bot", fullReply);
touchChatSessionStmt.run(targetChatId, user);
console.log("Reply length:", fullReply.length);
res.json({reply: fullReply});
} catch (err) {
console.error("AI chat error:", err);
res.status(500).json({error: "AI request failed"});
} finally {
state.activeRequests--;
if (state.activeRequests === 0) {
scheduleModelUnload(selectedModel);
}
}
});
app.get("/session", (req, res) => {
if (req.session.user) res.json({user: req.session.user});
else res.json({user: null});
});
app.get("/chat/history", requireLogin, (req, res) => {
const user = req.session.user;
const chats = listChatSessionsStmt.all(user);
res.json({history: chats});
});
app.get("/market", (req, res) => {
res.sendFile(path.resolve("public/market.html"));
});
app.get("/settings", (req, res) => {
res.sendFile(path.resolve("public/settings.html"));
});
app.get("/", (req, res) => {
res.sendFile(path.resolve("public/index.html"));
});
app.listen(PORT, () => console.log(`Server running at http://localhost:${PORT}`));