2255 lines
79 KiB
JavaScript
2255 lines
79 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,
|
|
assistant_persona_id
|
|
INTEGER,
|
|
user_persona_id
|
|
INTEGER,
|
|
scenario_prompt
|
|
TEXT,
|
|
scenario_summary
|
|
TEXT,
|
|
created_at
|
|
DATETIME
|
|
DEFAULT
|
|
CURRENT_TIMESTAMP,
|
|
updated_at
|
|
DATETIME
|
|
DEFAULT
|
|
CURRENT_TIMESTAMP,
|
|
FOREIGN
|
|
KEY
|
|
(
|
|
assistant_persona_id
|
|
) REFERENCES personas
|
|
(
|
|
id
|
|
) ON DELETE SET NULL,
|
|
FOREIGN
|
|
KEY
|
|
(
|
|
user_persona_id
|
|
) REFERENCES personas
|
|
(
|
|
id
|
|
) ON DELETE SET NULL
|
|
)
|
|
`).run();
|
|
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,
|
|
retry_variants
|
|
TEXT,
|
|
retry_active_index
|
|
INTEGER
|
|
DEFAULT 0,
|
|
retry_retries_used
|
|
INTEGER
|
|
DEFAULT 0,
|
|
retry_prompt_message_id
|
|
INTEGER,
|
|
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, assistant_persona_id, user_persona_id, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?)"
|
|
);
|
|
const listChatSessionsStmt = db.prepare(`
|
|
SELECT cs.id,
|
|
CASE
|
|
WHEN cs.assistant_persona_id IS NOT NULL THEN COALESCE(p.name, cs.title)
|
|
ELSE cs.title
|
|
END AS title,
|
|
cs.title AS stored_title,
|
|
cs.assistant_persona_id,
|
|
p.name AS assistant_persona_name,
|
|
cs.user_persona_id,
|
|
up.name AS user_persona_name,
|
|
cs.scenario_prompt,
|
|
cs.scenario_summary,
|
|
cs.created_at,
|
|
cs.updated_at
|
|
FROM chat_sessions cs
|
|
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
|
LEFT JOIN personas up ON up.id = cs.user_persona_id
|
|
WHERE cs.username = ?
|
|
ORDER BY datetime(cs.updated_at) DESC
|
|
`);
|
|
|
|
const countChatSessionsStmt = db.prepare(`
|
|
SELECT COUNT(*) as count
|
|
FROM chat_sessions
|
|
WHERE username = ?
|
|
`);
|
|
|
|
const getChatSessionStmt = db.prepare(`
|
|
SELECT cs.id,
|
|
CASE
|
|
WHEN cs.assistant_persona_id IS NOT NULL THEN COALESCE(p.name, cs.title)
|
|
ELSE cs.title
|
|
END AS title,
|
|
cs.title AS stored_title,
|
|
cs.assistant_persona_id,
|
|
p.name AS assistant_persona_name,
|
|
cs.user_persona_id,
|
|
up.name AS user_persona_name,
|
|
cs.scenario_prompt,
|
|
cs.scenario_summary
|
|
FROM chat_sessions cs
|
|
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
|
LEFT JOIN personas up ON up.id = cs.user_persona_id
|
|
WHERE cs.id = ?
|
|
AND cs.username = ?
|
|
`);
|
|
|
|
const getChatSessionByParticipantsStmt = db.prepare(`
|
|
SELECT cs.id,
|
|
CASE
|
|
WHEN cs.assistant_persona_id IS NOT NULL THEN COALESCE(p.name, cs.title)
|
|
ELSE cs.title
|
|
END AS title,
|
|
cs.title AS stored_title,
|
|
cs.assistant_persona_id,
|
|
p.name AS assistant_persona_name,
|
|
cs.user_persona_id,
|
|
up.name AS user_persona_name,
|
|
cs.scenario_prompt,
|
|
cs.scenario_summary
|
|
FROM chat_sessions cs
|
|
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
|
LEFT JOIN personas up ON up.id = cs.user_persona_id
|
|
WHERE cs.username = ?
|
|
AND cs.assistant_persona_id = ?
|
|
AND (
|
|
(cs.user_persona_id IS NULL AND ? IS NULL)
|
|
OR cs.user_persona_id = ?
|
|
)
|
|
ORDER BY datetime(cs.updated_at) DESC
|
|
LIMIT 1
|
|
`);
|
|
|
|
const updateChatSessionTitleStmt = db.prepare(
|
|
"UPDATE chat_sessions SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"
|
|
);
|
|
|
|
const updateChatSessionSceneStmt = db.prepare(
|
|
"UPDATE chat_sessions SET scenario_prompt = ?, scenario_summary = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"
|
|
);
|
|
|
|
const touchChatSessionStmt = db.prepare(
|
|
"UPDATE chat_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"
|
|
);
|
|
|
|
const deleteChatSessionStmt = db.prepare(
|
|
"DELETE FROM chat_sessions WHERE id = ? AND username = ?"
|
|
);
|
|
|
|
const clearAssistantPersonaFromChatsStmt = db.prepare(`
|
|
UPDATE chat_sessions
|
|
SET assistant_persona_id = NULL,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE username = ?
|
|
AND assistant_persona_id = ?
|
|
`);
|
|
|
|
const clearUserPersonaFromChatsStmt = db.prepare(`
|
|
UPDATE chat_sessions
|
|
SET user_persona_id = NULL,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE username = ?
|
|
AND user_persona_id = ?
|
|
`);
|
|
|
|
const insertChatMessageStmt = db.prepare(
|
|
"INSERT INTO chat_messages (chat_id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
);
|
|
|
|
const getChatMessagesStmt = db.prepare(`
|
|
SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id
|
|
FROM chat_messages
|
|
WHERE chat_id = ?
|
|
ORDER BY id
|
|
`
|
|
)
|
|
;
|
|
const getRecentChatMessagesStmt = db.prepare(`
|
|
SELECT role, content
|
|
FROM chat_messages
|
|
WHERE chat_id = ?
|
|
ORDER BY id DESC
|
|
LIMIT 20
|
|
`
|
|
)
|
|
;
|
|
|
|
const getRecentChatMessagesUpToIdStmt = db.prepare(`
|
|
SELECT role, content
|
|
FROM chat_messages
|
|
WHERE chat_id = ?
|
|
AND id <= ?
|
|
ORDER BY id DESC
|
|
LIMIT 20
|
|
`
|
|
)
|
|
;
|
|
|
|
const getChatMessageByIdStmt = db.prepare(`
|
|
SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id
|
|
FROM chat_messages
|
|
WHERE id = ?
|
|
AND chat_id = ?
|
|
`
|
|
)
|
|
;
|
|
|
|
const getChatMessageByIndexStmt = db.prepare(`
|
|
SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id
|
|
FROM chat_messages
|
|
WHERE chat_id = ?
|
|
ORDER BY id
|
|
LIMIT 1 OFFSET ?
|
|
`
|
|
)
|
|
;
|
|
|
|
const getLatestChatMessageStmt = db.prepare(`
|
|
SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id
|
|
FROM chat_messages
|
|
WHERE chat_id = ?
|
|
ORDER BY id DESC
|
|
LIMIT 1
|
|
`
|
|
)
|
|
;
|
|
|
|
const getPreviousUserMessageStmt = db.prepare(`
|
|
SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id
|
|
FROM chat_messages
|
|
WHERE chat_id = ?
|
|
AND role = 'user'
|
|
AND id < ?
|
|
ORDER BY id DESC
|
|
LIMIT 1
|
|
`
|
|
)
|
|
;
|
|
|
|
const updateChatMessageStmt = db.prepare(
|
|
"UPDATE chat_messages SET content = ? WHERE id = ? AND chat_id = ?"
|
|
);
|
|
|
|
const updateChatMessageWithRetryStateStmt = db.prepare(
|
|
"UPDATE chat_messages SET content = ?, retry_variants = ?, retry_active_index = ?, retry_retries_used = ?, retry_prompt_message_id = ? WHERE id = ? AND chat_id = ?"
|
|
);
|
|
|
|
const deleteChatMessageStmt = db.prepare(
|
|
"DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"
|
|
);
|
|
|
|
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 getAssistantPersonaForChatStmt = db.prepare(`
|
|
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
|
|
FROM chat_sessions cs
|
|
JOIN personas p ON p.id = cs.assistant_persona_id
|
|
WHERE cs.id = ?
|
|
AND cs.username = ?
|
|
`);
|
|
|
|
const getUserPersonaForChatStmt = db.prepare(`
|
|
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
|
|
FROM chat_sessions cs
|
|
JOIN personas p ON p.id = cs.user_persona_id
|
|
WHERE cs.id = ?
|
|
AND cs.username = ?
|
|
`);
|
|
|
|
const listMarketPersonasStmt = db.prepare(`
|
|
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", null, null).lastInsertRowid;
|
|
const legacyMessages = getLegacyChatsStmt.all(username);
|
|
legacyMessages.forEach(msg => {
|
|
insertChatMessage(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 chatMessageColumns = db.prepare("PRAGMA table_info(chat_messages)").all();
|
|
const hasRetryVariants = chatMessageColumns.some(column => column.name === "retry_variants");
|
|
if (!hasRetryVariants) {
|
|
db.prepare("ALTER TABLE chat_messages ADD COLUMN retry_variants TEXT").run();
|
|
}
|
|
const hasRetryActiveIndex = chatMessageColumns.some(column => column.name === "retry_active_index");
|
|
if (!hasRetryActiveIndex) {
|
|
db.prepare("ALTER TABLE chat_messages ADD COLUMN retry_active_index INTEGER DEFAULT 0").run();
|
|
}
|
|
const hasRetryRetriesUsed = chatMessageColumns.some(column => column.name === "retry_retries_used");
|
|
if (!hasRetryRetriesUsed) {
|
|
db.prepare("ALTER TABLE chat_messages ADD COLUMN retry_retries_used INTEGER DEFAULT 0").run();
|
|
}
|
|
const hasRetryPromptMessageId = chatMessageColumns.some(column => column.name === "retry_prompt_message_id");
|
|
if (!hasRetryPromptMessageId) {
|
|
db.prepare("ALTER TABLE chat_messages ADD COLUMN retry_prompt_message_id INTEGER").run();
|
|
}
|
|
db.prepare("UPDATE chat_messages SET retry_active_index = 0 WHERE retry_active_index IS NULL").run();
|
|
db.prepare("UPDATE chat_messages SET retry_retries_used = 0 WHERE retry_retries_used IS NULL").run();
|
|
|
|
const chatSessionColumns = db.prepare("PRAGMA table_info(chat_sessions)").all();
|
|
const hasAssistantPersonaLink = chatSessionColumns.some(column => column.name === "assistant_persona_id");
|
|
if (!hasAssistantPersonaLink) {
|
|
db.prepare("ALTER TABLE chat_sessions ADD COLUMN assistant_persona_id INTEGER").run();
|
|
}
|
|
const hasChatUserPersonaLink = chatSessionColumns.some(column => column.name === "user_persona_id");
|
|
if (!hasChatUserPersonaLink) {
|
|
db.prepare("ALTER TABLE chat_sessions ADD COLUMN user_persona_id INTEGER").run();
|
|
}
|
|
const hasScenarioPrompt = chatSessionColumns.some(column => column.name === "scenario_prompt");
|
|
if (!hasScenarioPrompt) {
|
|
db.prepare("ALTER TABLE chat_sessions ADD COLUMN scenario_prompt TEXT").run();
|
|
}
|
|
const hasScenarioSummary = chatSessionColumns.some(column => column.name === "scenario_summary");
|
|
if (!hasScenarioSummary) {
|
|
db.prepare("ALTER TABLE chat_sessions ADD COLUMN scenario_summary TEXT").run();
|
|
}
|
|
|
|
const FileStore = FileStoreFactory(session);
|
|
app.use(bodyParser.json());
|
|
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 speak as, write for, decide for, or roleplay as the user or the user's persona.",
|
|
"Do NOT narrate the user's dialogue, thoughts, feelings, choices, or actions.",
|
|
"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 write this character's dialogue, thoughts, feelings, choices, or actions unless the user explicitly supplies them.",
|
|
"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");
|
|
};
|
|
|
|
const summarizePersona = (persona) => {
|
|
if (!persona) return "";
|
|
return [
|
|
persona.name ? `Name: ${persona.name}` : null,
|
|
persona.pronouns ? `Pronouns: ${persona.pronouns}` : null,
|
|
persona.background ? `Background: ${persona.background}` : null,
|
|
persona.details ? `Traits: ${persona.details}` : null
|
|
].filter(Boolean).join(" | ");
|
|
};
|
|
|
|
const clampText = (value, limit = 260) => {
|
|
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
|
if (normalized.length <= limit) return normalized;
|
|
return `${normalized.slice(0, limit - 3).trimEnd()}...`;
|
|
};
|
|
|
|
const normalizeOptionalText = (value) => {
|
|
const normalized = String(value || "").trim();
|
|
return normalized || null;
|
|
};
|
|
|
|
const buildRoleplaySceneSummary = (assistantPersona, userPersona, scenarioPrompt = "") => {
|
|
const assistantName = assistantPersona?.name || "The assistant";
|
|
const userName = userPersona?.name || "the user";
|
|
const assistantHook = assistantPersona?.details || assistantPersona?.background || "stays strongly in character";
|
|
const userHook = userPersona?.details || userPersona?.background || "enters the scene as themselves";
|
|
const scenarioSeed = scenarioPrompt
|
|
? clampText(scenarioPrompt, 180)
|
|
: `A fitting opening grows naturally from ${assistantName}'s role, goals, and manner toward ${userName}.`;
|
|
return clampText(
|
|
`${assistantName} leads the opening. Their approach is shaped by ${assistantHook}. ` +
|
|
`${userName} is framed through ${userHook}. ` +
|
|
`Scene seed: ${scenarioSeed}`,
|
|
340
|
|
);
|
|
};
|
|
|
|
const buildRoleplayDirectionPrompt = ({assistantPersona, userPersona, scenarioPrompt, sceneSummary}) => {
|
|
const lines = [
|
|
"ROLEPLAY DIRECTION:",
|
|
"Create an immersive one-on-one roleplay between the assistant persona and the user.",
|
|
"Ground the interaction in a specific scenario, place, and immediate dramatic situation.",
|
|
"Make the assistant's first instinct, language, and priorities fit their persona exactly.",
|
|
"Use the user persona as interaction context so the assistant addresses them in a fitting way.",
|
|
"Format scene description, body language, and nonverbal actions in italics using *word*.",
|
|
"Format direct speech in double quotes like \"word\".",
|
|
"Format shouted words, sharp emphasis, or explosive expressions in bold using **word**.",
|
|
"Keep formatting readable and intentional instead of wrapping every sentence in markdown.",
|
|
"Avoid generic openings, meta commentary, and requests for permission to begin.",
|
|
"Do not write dialogue, internal thoughts, choices, or actions for the user.",
|
|
"Never output any lines like 'User:' or 'You:' and never narrate what the user does.",
|
|
"The opening must contain only the assistant character's own spoken words and optional self-actions.",
|
|
"Prefer a concrete opening beat over vague exposition.",
|
|
"Include a subtle hook, tension, invitation, or problem that gives the user something to respond to.",
|
|
"",
|
|
`SCENE SUMMARY: ${sceneSummary}`
|
|
];
|
|
if (scenarioPrompt) {
|
|
lines.push(`USER SCENARIO SEED: ${scenarioPrompt}`);
|
|
}
|
|
if (assistantPersona) {
|
|
lines.push("", `ASSISTANT SNAPSHOT: ${summarizePersona(assistantPersona)}`);
|
|
}
|
|
if (userPersona) {
|
|
lines.push(`USER SNAPSHOT: ${summarizePersona(userPersona)}`);
|
|
}
|
|
return lines.join("\n");
|
|
};
|
|
|
|
const buildRoleplayOpenerPrompt = ({assistantPersona, userPersona, scenarioPrompt, sceneSummary}) => {
|
|
const assistantName = assistantPersona?.name || "the assistant";
|
|
const userName = userPersona?.name || "the user";
|
|
const sceneSeed = scenarioPrompt
|
|
? `Use this scenario seed: ${scenarioPrompt}`
|
|
: `Invent a fresh scenario that suits ${assistantName} and how they would realistically meet or confront ${userName}.`;
|
|
|
|
return [
|
|
`Write the first in-character message from ${assistantName}.`,
|
|
sceneSeed,
|
|
`Address ${userName} naturally within the scene.`,
|
|
"The message should establish the situation immediately instead of explaining setup out of character.",
|
|
"Use 1 to 3 short paragraphs. Sensory detail is allowed, but keep momentum.",
|
|
"Write scene description, body language, and nonverbal actions in italics using *word*.",
|
|
"Write direct speech in double quotes like \"word\".",
|
|
"Use bold markdown like **word** only for loud, forceful, or strongly emphasized expressions.",
|
|
"Write only the assistant character's words and optional self-actions.",
|
|
"Do not write any user dialogue, thoughts, feelings, choices, or actions.",
|
|
"Do not use labels such as 'User:' or 'You:'.",
|
|
"End with a line, question, action, or reveal that gives the user an obvious way to answer.",
|
|
`Keep this scene continuity in mind: ${sceneSummary}`
|
|
].join("\n");
|
|
};
|
|
|
|
const escapeRegExp = (value) => String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
|
const containsUserVoiceInRoleplayOpener = (opener, userPersona) => {
|
|
const text = String(opener || "").trim();
|
|
if (!text) return true;
|
|
|
|
const userName = (userPersona?.name || "").trim();
|
|
const nameParts = userName ? userName.split(/\s+/).filter(Boolean).slice(0, 2) : [];
|
|
const tokens = ["user", "you", ...nameParts].filter(Boolean).map(escapeRegExp);
|
|
if (!tokens.length) return false;
|
|
|
|
const labelPattern = new RegExp(`(^|\\n)\\s*(?:${tokens.join("|")})\\s*:`, "i");
|
|
if (labelPattern.test(text)) return true;
|
|
|
|
const userActionPattern = new RegExp(
|
|
`(^|\\n)\\s*(?:\\*\\s*)?(?:${tokens.join("|")})\\s+` +
|
|
"(?:say|says|said|ask|asks|asked|reply|replies|replied|think|thinks|thought|feel|feels|felt|walk|walks|walked|step|steps|stepped|look|looks|looked|nod|nods|nodded|smile|smiles|smiled|enter|enters|entered|turn|turns|turned)\\b",
|
|
"i"
|
|
);
|
|
return userActionPattern.test(text);
|
|
};
|
|
|
|
const generateRoleplayOpener = async ({selectedModel = "mistral:latest", assistantPersona, userPersona, scenarioPrompt, sceneSummary}) => {
|
|
const baseMessages = [
|
|
{role: "system", content: buildPersonaPrompt(assistantPersona)},
|
|
...(userPersona ? [{role: "system", content: buildUserPersonaPrompt(userPersona)}] : []),
|
|
{role: "system", content: buildRoleplayDirectionPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary})},
|
|
{role: "user", content: buildRoleplayOpenerPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary})}
|
|
];
|
|
|
|
let opener = await generateModelReply(selectedModel, baseMessages);
|
|
if (!containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
|
return opener;
|
|
}
|
|
|
|
const retryMessages = [
|
|
...baseMessages,
|
|
{role: "assistant", content: opener || ""},
|
|
{
|
|
role: "user",
|
|
content: "Rewrite this opening. Hard rule: only the assistant character may speak or act. Never write user speech, thoughts, actions, or labels like 'User:'/'You:'."
|
|
}
|
|
];
|
|
opener = await generateModelReply(selectedModel, retryMessages);
|
|
return opener;
|
|
};
|
|
|
|
const buildRetryPayload = async ({chatId, user, session, selectedModel, targetMessage}) => {
|
|
const activePersona = getAssistantPersonaForChatStmt.get(chatId, user);
|
|
const activeUserPersona = getUserPersonaForChatStmt.get(chatId, user) || getActiveUserPersonaStmt.get(user);
|
|
const sceneSummary = session.scenario_summary;
|
|
const scenarioPrompt = session.scenario_prompt;
|
|
const promptMessage = getPreviousUserMessageStmt.get(chatId, targetMessage.id);
|
|
|
|
if (!promptMessage) {
|
|
if (!activePersona || !sceneSummary) {
|
|
return {error: "No previous user prompt found for retry"};
|
|
}
|
|
const opener = await generateRoleplayOpener({
|
|
selectedModel,
|
|
assistantPersona: activePersona,
|
|
userPersona: activeUserPersona,
|
|
scenarioPrompt,
|
|
sceneSummary
|
|
});
|
|
if (!opener || containsUserVoiceInRoleplayOpener(opener, activeUserPersona)) {
|
|
return {error: "Failed to regenerate message"};
|
|
}
|
|
return {fullReply: opener, promptMessageId: null};
|
|
}
|
|
|
|
const conversation = getRecentChatMessagesUpToIdStmt.all(chatId, promptMessage.id).reverse();
|
|
const messagesPayload = [];
|
|
if (activePersona) {
|
|
messagesPayload.push({
|
|
role: "system",
|
|
content: buildPersonaPrompt(activePersona)
|
|
});
|
|
}
|
|
if (activeUserPersona) {
|
|
messagesPayload.push({
|
|
role: "system",
|
|
content: buildUserPersonaPrompt(activeUserPersona)
|
|
});
|
|
}
|
|
if (activePersona && sceneSummary) {
|
|
messagesPayload.push({
|
|
role: "system",
|
|
content: buildRoleplayDirectionPrompt({
|
|
assistantPersona: activePersona,
|
|
userPersona: activeUserPersona,
|
|
scenarioPrompt,
|
|
sceneSummary
|
|
})
|
|
});
|
|
}
|
|
messagesPayload.push(
|
|
...conversation.map((m) => ({
|
|
role: m.role === "bot" ? "assistant" : "user",
|
|
content: m.content
|
|
}))
|
|
);
|
|
|
|
const fullReply = await generateModelReply(selectedModel, messagesPayload);
|
|
if (!fullReply) {
|
|
return {error: "Failed to regenerate message"};
|
|
}
|
|
return {fullReply, promptMessageId: promptMessage.id};
|
|
};
|
|
|
|
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 {
|
|
// Ignore invalid stored retry history and fall back to current content.
|
|
}
|
|
return [content || ""];
|
|
};
|
|
|
|
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 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,
|
|
retryVariants,
|
|
retryActiveIndex,
|
|
retryRetriesUsed: Number(row.retry_retries_used || 0),
|
|
retryPromptMessageId: row.retry_prompt_message_id ?? null
|
|
};
|
|
};
|
|
|
|
const persistChatMessageRetryState = ({chatId, messageId, content, retryVariants, retryActiveIndex, retryRetriesUsed, retryPromptMessageId}) => {
|
|
return updateChatMessageWithRetryStateStmt.run(
|
|
content,
|
|
JSON.stringify(retryVariants || [content || ""]),
|
|
clampRetryActiveIndex(retryActiveIndex, retryVariants || [content || ""]),
|
|
Number(retryRetriesUsed || 0),
|
|
retryPromptMessageId ?? null,
|
|
messageId,
|
|
chatId
|
|
);
|
|
};
|
|
|
|
const insertChatMessage = (chatId, role, content, retryState = null) => {
|
|
const normalizedContent = String(content || "");
|
|
const payload = retryState
|
|
? {
|
|
retryVariants: Array.isArray(retryState.retryVariants) && retryState.retryVariants.length ? retryState.retryVariants : [normalizedContent],
|
|
retryActiveIndex: retryState.retryActiveIndex ?? 0,
|
|
retryRetriesUsed: retryState.retryRetriesUsed ?? 0,
|
|
retryPromptMessageId: retryState.retryPromptMessageId ?? null
|
|
}
|
|
: null;
|
|
return insertChatMessageStmt.run(
|
|
chatId,
|
|
role,
|
|
normalizedContent,
|
|
payload ? JSON.stringify(payload.retryVariants) : null,
|
|
payload ? clampRetryActiveIndex(payload.retryActiveIndex, payload.retryVariants) : 0,
|
|
payload ? Number(payload.retryRetriesUsed || 0) : 0,
|
|
payload ? payload.retryPromptMessageId : null
|
|
);
|
|
};
|
|
|
|
const getChatMessageByIndex = (chatId, index) => {
|
|
const safeIndex = Number(index);
|
|
if (!Number.isInteger(safeIndex) || safeIndex < 0) return null;
|
|
return getChatMessageByIndexStmt.get(chatId, safeIndex) || null;
|
|
};
|
|
|
|
const createPersonaChat = (user, persona, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null) => {
|
|
const chatId = insertChatSessionStmt.run(user, persona.name, persona.id, userPersonaId, scenarioPrompt, scenarioSummary).lastInsertRowid;
|
|
return getChatSessionStmt.get(chatId, user);
|
|
};
|
|
|
|
const getOrCreatePersonaChat = (user, persona, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null) => {
|
|
const existing = getChatSessionByParticipantsStmt.get(user, persona.id, userPersonaId, userPersonaId);
|
|
if (existing) return existing;
|
|
const sessionCount = countChatSessionsStmt.get(user).count;
|
|
if (sessionCount >= 10) {
|
|
throw new Error("CHAT_LIMIT_REACHED");
|
|
}
|
|
return createPersonaChat(user, persona, userPersonaId, scenarioPrompt, scenarioSummary);
|
|
};
|
|
|
|
const ensureModelState = (model) => {
|
|
if (!modelState.has(model)) {
|
|
modelState.set(model, {activeRequests: 0, timer: null});
|
|
}
|
|
return modelState.get(model);
|
|
};
|
|
|
|
const generateModelReply = async (selectedModel, messagesPayload) => {
|
|
const state = ensureModelState(selectedModel);
|
|
state.activeRequests++;
|
|
try {
|
|
const response = await fetch("https://ai.krishd.ch/api/chat", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({
|
|
model: selectedModel,
|
|
messages: messagesPayload,
|
|
stream: true
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`AI HTTP ${response.status}`);
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
let fullReply = "";
|
|
|
|
while (true) {
|
|
const {value, done} = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, {stream: true});
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
const data = JSON.parse(trimmed);
|
|
if (data.message?.content) fullReply += data.message.content;
|
|
}
|
|
}
|
|
|
|
if (buffer.trim()) {
|
|
const data = JSON.parse(buffer.trim());
|
|
if (data.message?.content) fullReply += data.message.content;
|
|
}
|
|
|
|
return fullReply.trim();
|
|
} finally {
|
|
state.activeRequests = Math.max(0, state.activeRequests - 1);
|
|
scheduleModelUnload(selectedModel);
|
|
}
|
|
};
|
|
|
|
app.get("/models", async (req, res) => {
|
|
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 assistantPersonaId = req.body?.assistantPersonaId ? Number(req.body.assistantPersonaId) : null;
|
|
if (assistantPersonaId) {
|
|
const persona = getPersonaStmt.get(assistantPersonaId, user);
|
|
if (!persona || persona.persona_type !== "assistant") {
|
|
return res.status(404).json({error: "AI Character not found"});
|
|
}
|
|
const existingChat = getChatSessionByParticipantsStmt.get(user, assistantPersonaId, null, null);
|
|
try {
|
|
const chat = getOrCreatePersonaChat(user, persona, null);
|
|
return res.json({chat, existing: Boolean(existingChat)});
|
|
} catch (error) {
|
|
if (error.message === "CHAT_LIMIT_REACHED") {
|
|
return res.status(400).json({error: "Maximum of 10 chats reached. Please delete an old chat to create a new one."});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const sessionCount = countChatSessionsStmt.get(user).count;
|
|
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, null, null, null, null).lastInsertRowid;
|
|
const chat = getChatSessionStmt.get(chatId, user);
|
|
res.json({chat, existing: false});
|
|
});
|
|
|
|
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).map(formatChatMessage);
|
|
res.json({messages, chat: session});
|
|
});
|
|
|
|
app.put("/chats/:id/messages/:messageId", requireLogin, (req, res) => {
|
|
const user = req.session.user;
|
|
const chatId = Number(req.params.id);
|
|
const messageId = Number(req.params.messageId);
|
|
const content = String(req.body?.content || "").trim();
|
|
const session = getChatSessionStmt.get(chatId, user);
|
|
if (!session) {
|
|
return res.status(404).json({error: "Chat not found"});
|
|
}
|
|
if (!content) {
|
|
return res.status(400).json({error: "Message content is required"});
|
|
}
|
|
const existingMessage = formatChatMessage(getChatMessageByIdStmt.get(messageId, chatId));
|
|
if (!existingMessage) {
|
|
return res.status(404).json({error: "Message not found"});
|
|
}
|
|
const requestedRetryActiveIndex = Number(req.body?.retryActiveIndex);
|
|
if (existingMessage.role === "bot") {
|
|
const retryVariants = [...existingMessage.retryVariants];
|
|
const nextActiveIndex = Number.isInteger(requestedRetryActiveIndex)
|
|
? clampRetryActiveIndex(requestedRetryActiveIndex, retryVariants)
|
|
: existingMessage.retryActiveIndex;
|
|
retryVariants[nextActiveIndex] = content;
|
|
persistChatMessageRetryState({
|
|
chatId,
|
|
messageId,
|
|
content,
|
|
retryVariants,
|
|
retryActiveIndex: nextActiveIndex,
|
|
retryRetriesUsed: existingMessage.retryRetriesUsed,
|
|
retryPromptMessageId: existingMessage.retryPromptMessageId
|
|
});
|
|
} else {
|
|
updateChatMessageStmt.run(content, messageId, chatId);
|
|
}
|
|
touchChatSessionStmt.run(chatId, user);
|
|
const message = formatChatMessage(getChatMessageByIdStmt.get(messageId, chatId));
|
|
res.json({message});
|
|
});
|
|
|
|
app.put("/chats/:id/messages/by-index/:index", requireLogin, (req, res) => {
|
|
const user = req.session.user;
|
|
const chatId = Number(req.params.id);
|
|
const content = String(req.body?.content || "").trim();
|
|
const session = getChatSessionStmt.get(chatId, user);
|
|
if (!session) {
|
|
return res.status(404).json({error: "Chat not found"});
|
|
}
|
|
if (!content) {
|
|
return res.status(400).json({error: "Message content is required"});
|
|
}
|
|
const target = getChatMessageByIndex(chatId, req.params.index);
|
|
if (!target) {
|
|
return res.status(404).json({error: "Message not found"});
|
|
}
|
|
const existingMessage = formatChatMessage(getChatMessageByIdStmt.get(target.id, chatId));
|
|
if (!existingMessage) {
|
|
return res.status(404).json({error: "Message not found"});
|
|
}
|
|
const requestedRetryActiveIndex = Number(req.body?.retryActiveIndex);
|
|
if (existingMessage.role === "bot") {
|
|
const retryVariants = [...existingMessage.retryVariants];
|
|
const nextActiveIndex = Number.isInteger(requestedRetryActiveIndex)
|
|
? clampRetryActiveIndex(requestedRetryActiveIndex, retryVariants)
|
|
: existingMessage.retryActiveIndex;
|
|
retryVariants[nextActiveIndex] = content;
|
|
persistChatMessageRetryState({
|
|
chatId,
|
|
messageId: target.id,
|
|
content,
|
|
retryVariants,
|
|
retryActiveIndex: nextActiveIndex,
|
|
retryRetriesUsed: existingMessage.retryRetriesUsed,
|
|
retryPromptMessageId: existingMessage.retryPromptMessageId
|
|
});
|
|
} else {
|
|
updateChatMessageStmt.run(content, target.id, chatId);
|
|
}
|
|
touchChatSessionStmt.run(chatId, user);
|
|
const message = formatChatMessage(getChatMessageByIdStmt.get(target.id, chatId));
|
|
res.json({message});
|
|
});
|
|
|
|
app.delete("/chats/:id/messages/:messageId", requireLogin, (req, res) => {
|
|
const user = req.session.user;
|
|
const chatId = Number(req.params.id);
|
|
const messageId = Number(req.params.messageId);
|
|
const session = getChatSessionStmt.get(chatId, user);
|
|
if (!session) {
|
|
return res.status(404).json({error: "Chat not found"});
|
|
}
|
|
const result = deleteChatMessageStmt.run(messageId, chatId);
|
|
if (result.changes === 0) {
|
|
return res.status(404).json({error: "Message not found"});
|
|
}
|
|
touchChatSessionStmt.run(chatId, user);
|
|
res.json({message: "Message deleted"});
|
|
});
|
|
|
|
app.delete("/chats/:id/messages/by-index/:index", 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 target = getChatMessageByIndex(chatId, req.params.index);
|
|
if (!target) {
|
|
return res.status(404).json({error: "Message not found"});
|
|
}
|
|
deleteChatMessageStmt.run(target.id, chatId);
|
|
touchChatSessionStmt.run(chatId, user);
|
|
res.json({message: "Message deleted"});
|
|
});
|
|
|
|
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,
|
|
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_user_persona_id === personaId) {
|
|
setActiveUserPersonaStmt.run(user, null);
|
|
}
|
|
clearAssistantPersonaFromChatsStmt.run(user, personaId);
|
|
clearUserPersonaFromChatsStmt.run(user, personaId);
|
|
deleteMarketPersonaByPersonaIdStmt.run(personaId, user);
|
|
res.json({message: "Persona deleted"});
|
|
});
|
|
|
|
app.post("/personas/:id/chat", requireLogin, (req, res) => {
|
|
const user = req.session.user;
|
|
const personaId = Number(req.params.id);
|
|
const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null;
|
|
const persona = getPersonaStmt.get(personaId, user);
|
|
if (!persona || persona.persona_type !== "assistant") {
|
|
return res.status(404).json({error: "Persona not found"});
|
|
}
|
|
if (userPersonaId) {
|
|
const userPersona = getPersonaStmt.get(userPersonaId, user);
|
|
if (!userPersona || userPersona.persona_type !== "user") {
|
|
return res.status(404).json({error: "User persona not found"});
|
|
}
|
|
}
|
|
let chat;
|
|
try {
|
|
chat = getOrCreatePersonaChat(user, persona, userPersonaId, null, null);
|
|
} catch (error) {
|
|
if (error.message === "CHAT_LIMIT_REACHED") {
|
|
return res.status(400).json({error: "Maximum of 10 chats reached. Please delete an old chat to create a new one."});
|
|
}
|
|
throw error;
|
|
}
|
|
res.json({chat});
|
|
});
|
|
|
|
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/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 activeUserPersonaId = null;
|
|
if (req.body?.equip) {
|
|
if (marketPersona.persona_type === "user") {
|
|
setActiveUserPersonaStmt.run(user, personaId);
|
|
activeUserPersonaId = personaId;
|
|
}
|
|
}
|
|
const persona = getPersonaStmt.get(personaId, user);
|
|
res.json({persona, activeUserPersonaId});
|
|
});
|
|
|
|
app.post("/personas/market/:id/chat", requireLogin, async (req, res) => {
|
|
const user = req.session.user;
|
|
const marketId = Number(req.params.id);
|
|
const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null;
|
|
const marketPersona = getMarketPersonaStmt.get(marketId);
|
|
if (!marketPersona || marketPersona.persona_type !== "assistant") {
|
|
return res.status(404).json({error: "Market AI Character not found"});
|
|
}
|
|
let userPersona = null;
|
|
if (userPersonaId) {
|
|
userPersona = getPersonaStmt.get(userPersonaId, user);
|
|
if (!userPersona || userPersona.persona_type !== "user") {
|
|
return res.status(404).json({error: "User persona not found"});
|
|
}
|
|
}
|
|
|
|
let personaId = null;
|
|
const existingPersona = getPersonaBySourceMarketStmt.get(user, marketId);
|
|
if (existingPersona) {
|
|
personaId = existingPersona.id;
|
|
} else {
|
|
personaId = insertMarketPersonaStmt.run(
|
|
user,
|
|
marketPersona.name,
|
|
marketPersona.pronouns,
|
|
marketPersona.appearance,
|
|
marketPersona.background,
|
|
marketPersona.details,
|
|
marketPersona.example_dialogues,
|
|
marketPersona.persona_type,
|
|
marketId,
|
|
marketPersona.creator_username
|
|
).lastInsertRowid;
|
|
incrementMarketUsageCountStmt.run(marketId);
|
|
}
|
|
|
|
const persona = getPersonaStmt.get(personaId, user);
|
|
const scenarioPrompt = normalizeOptionalText(req.body?.scenarioPrompt);
|
|
const sceneSummary = buildRoleplaySceneSummary(persona, userPersona, scenarioPrompt);
|
|
let chat;
|
|
try {
|
|
chat = getOrCreatePersonaChat(user, persona, userPersonaId, scenarioPrompt, sceneSummary);
|
|
} catch (error) {
|
|
if (error.message === "CHAT_LIMIT_REACHED") {
|
|
return res.status(400).json({error: "Maximum of 10 chats reached. Please delete an old chat to create a new one."});
|
|
}
|
|
throw error;
|
|
}
|
|
const existingMessages = getChatMessagesStmt.all(chat.id);
|
|
if (existingMessages.length > 0) {
|
|
return res.json({persona, chat, existing: true, generatedInitialMessage: false});
|
|
}
|
|
updateChatSessionSceneStmt.run(scenarioPrompt, sceneSummary, chat.id, user);
|
|
chat = getChatSessionStmt.get(chat.id, user);
|
|
try {
|
|
const opener = await generateRoleplayOpener({
|
|
selectedModel: "mistral:latest",
|
|
assistantPersona: persona,
|
|
userPersona,
|
|
scenarioPrompt,
|
|
sceneSummary
|
|
});
|
|
if (!opener || containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
|
return res.json({persona, chat, existing: false, generatedInitialMessage: false});
|
|
}
|
|
insertChatMessage(chat.id, "bot", opener, {
|
|
retryVariants: [opener],
|
|
retryActiveIndex: 0,
|
|
retryRetriesUsed: 0,
|
|
retryPromptMessageId: null
|
|
});
|
|
touchChatSessionStmt.run(chat.id, user);
|
|
return res.json({persona, chat: getChatSessionStmt.get(chat.id, user), existing: false, generatedInitialMessage: true, opener});
|
|
} catch {
|
|
return res.json({persona, chat, existing: false, generatedInitialMessage: false});
|
|
}
|
|
});
|
|
|
|
app.post("/roleplays/start", requireLogin, async (req, res) => {
|
|
const user = req.session.user;
|
|
const selectedModel = req.body?.model || "mistral:latest";
|
|
const assistantPersonaId = Number(req.body?.assistantPersonaId);
|
|
const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null;
|
|
const scenarioPrompt = normalizeOptionalText(req.body?.scenarioPrompt);
|
|
|
|
if (!assistantPersonaId) {
|
|
return res.status(400).json({error: "assistantPersonaId is required"});
|
|
}
|
|
|
|
const assistantPersona = getPersonaStmt.get(assistantPersonaId, user);
|
|
if (!assistantPersona || assistantPersona.persona_type !== "assistant") {
|
|
return res.status(404).json({error: "Character not found"});
|
|
}
|
|
|
|
let userPersona = null;
|
|
if (userPersonaId) {
|
|
userPersona = getPersonaStmt.get(userPersonaId, user);
|
|
if (!userPersona || userPersona.persona_type !== "user") {
|
|
return res.status(404).json({error: "User persona not found"});
|
|
}
|
|
}
|
|
|
|
let chat;
|
|
const sceneSummary = buildRoleplaySceneSummary(assistantPersona, userPersona, scenarioPrompt);
|
|
try {
|
|
chat = getOrCreatePersonaChat(user, assistantPersona, userPersonaId, scenarioPrompt, sceneSummary);
|
|
} catch (error) {
|
|
if (error.message === "CHAT_LIMIT_REACHED") {
|
|
return res.status(400).json({error: "Maximum of 10 chats reached. Please delete an old chat to create a new one."});
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const existingMessages = getChatMessagesStmt.all(chat.id);
|
|
if (existingMessages.length > 0) {
|
|
return res.json({chat, existing: true, generatedInitialMessage: false});
|
|
}
|
|
updateChatSessionSceneStmt.run(scenarioPrompt, sceneSummary, chat.id, user);
|
|
chat = getChatSessionStmt.get(chat.id, user);
|
|
|
|
try {
|
|
const opener = await generateRoleplayOpener({
|
|
selectedModel,
|
|
assistantPersona,
|
|
userPersona,
|
|
scenarioPrompt,
|
|
sceneSummary
|
|
});
|
|
if (!opener || containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
|
return res.status(500).json({error: "Failed to generate the character opener"});
|
|
}
|
|
insertChatMessage(chat.id, "bot", opener, {
|
|
retryVariants: [opener],
|
|
retryActiveIndex: 0,
|
|
retryRetriesUsed: 0,
|
|
retryPromptMessageId: null
|
|
});
|
|
touchChatSessionStmt.run(chat.id, user);
|
|
return res.json({chat: getChatSessionStmt.get(chat.id, user), existing: false, generatedInitialMessage: true, opener});
|
|
} catch {
|
|
return res.status(500).json({error: "Failed to generate the character opener"});
|
|
}
|
|
});
|
|
|
|
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"});
|
|
}
|
|
|
|
try {
|
|
const activePersona = getAssistantPersonaForChatStmt.get(targetChatId, user);
|
|
const activeUserPersona = getUserPersonaForChatStmt.get(targetChatId, user) || getActiveUserPersonaStmt.get(user);
|
|
const sceneSummary = session.scenario_summary;
|
|
const scenarioPrompt = session.scenario_prompt;
|
|
const history = getRecentChatMessagesStmt.all(targetChatId);
|
|
const conversation = [...history].reverse();
|
|
conversation.push({role: "user", content: message});
|
|
insertChatMessage(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)
|
|
});
|
|
}
|
|
if (activePersona && sceneSummary) {
|
|
messagesPayload.push({
|
|
role: "system",
|
|
content: buildRoleplayDirectionPrompt({
|
|
assistantPersona: activePersona,
|
|
userPersona: activeUserPersona,
|
|
scenarioPrompt,
|
|
sceneSummary
|
|
})
|
|
});
|
|
}
|
|
messagesPayload.push(
|
|
...conversation.map(m => ({
|
|
role: m.role === "bot" ? "assistant" : "user",
|
|
content: m.content
|
|
}))
|
|
);
|
|
const fullReply = await generateModelReply(selectedModel, messagesPayload);
|
|
insertChatMessage(targetChatId, "bot", fullReply, {
|
|
retryVariants: [fullReply],
|
|
retryActiveIndex: 0,
|
|
retryRetriesUsed: 0,
|
|
retryPromptMessageId: null
|
|
});
|
|
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"});
|
|
}
|
|
});
|
|
|
|
app.post("/chats/:id/messages/:messageId/retry", requireLogin, async (req, res) => {
|
|
const user = req.session.user;
|
|
const chatId = Number(req.params.id);
|
|
const messageId = Number(req.params.messageId);
|
|
const selectedModel = req.body?.model || "mistral:latest";
|
|
const session = getChatSessionStmt.get(chatId, user);
|
|
if (!session) {
|
|
return res.status(404).json({error: "Chat not found"});
|
|
}
|
|
|
|
const targetMessage = getChatMessageByIdStmt.get(messageId, chatId);
|
|
if (!targetMessage) {
|
|
return res.status(404).json({error: "Message not found"});
|
|
}
|
|
const latestMessage = getLatestChatMessageStmt.get(chatId);
|
|
if (!latestMessage || latestMessage.id !== messageId || latestMessage.role !== "bot") {
|
|
return res.status(400).json({error: "Only the newest assistant message can be retried"});
|
|
}
|
|
try {
|
|
const retryResult = await buildRetryPayload({chatId, user, session, selectedModel, targetMessage});
|
|
if (retryResult.error) {
|
|
return res.status(400).json({error: retryResult.error});
|
|
}
|
|
const {fullReply, promptMessageId} = retryResult;
|
|
const existingMessage = formatChatMessage(targetMessage);
|
|
const retryVariants = [...existingMessage.retryVariants, fullReply];
|
|
const retryActiveIndex = retryVariants.length - 1;
|
|
const retryRetriesUsed = existingMessage.retryRetriesUsed + 1;
|
|
persistChatMessageRetryState({
|
|
chatId,
|
|
messageId,
|
|
content: fullReply,
|
|
retryVariants,
|
|
retryActiveIndex,
|
|
retryRetriesUsed,
|
|
retryPromptMessageId: promptMessageId
|
|
});
|
|
touchChatSessionStmt.run(chatId, user);
|
|
return res.json({message: formatChatMessage(getChatMessageByIdStmt.get(messageId, chatId)), promptMessageId});
|
|
} catch (err) {
|
|
console.error("Retry error:", err);
|
|
return res.status(500).json({error: "Failed to regenerate message"});
|
|
}
|
|
});
|
|
|
|
app.post("/chats/:id/messages/by-index/:index/retry", requireLogin, async (req, res) => {
|
|
const user = req.session.user;
|
|
const chatId = Number(req.params.id);
|
|
const selectedModel = req.body?.model || "mistral:latest";
|
|
const session = getChatSessionStmt.get(chatId, user);
|
|
if (!session) {
|
|
return res.status(404).json({error: "Chat not found"});
|
|
}
|
|
|
|
const targetMessage = getChatMessageByIndex(chatId, req.params.index);
|
|
if (!targetMessage) {
|
|
return res.status(404).json({error: "Message not found"});
|
|
}
|
|
const latestMessage = getLatestChatMessageStmt.get(chatId);
|
|
if (!latestMessage || latestMessage.id !== targetMessage.id || latestMessage.role !== "bot") {
|
|
return res.status(400).json({error: "Only the newest assistant message can be retried"});
|
|
}
|
|
try {
|
|
const retryResult = await buildRetryPayload({chatId, user, session, selectedModel, targetMessage});
|
|
if (retryResult.error) {
|
|
return res.status(400).json({error: retryResult.error});
|
|
}
|
|
const {fullReply, promptMessageId} = retryResult;
|
|
const existingMessage = formatChatMessage(targetMessage);
|
|
const retryVariants = [...existingMessage.retryVariants, fullReply];
|
|
const retryActiveIndex = retryVariants.length - 1;
|
|
const retryRetriesUsed = existingMessage.retryRetriesUsed + 1;
|
|
persistChatMessageRetryState({
|
|
chatId,
|
|
messageId: targetMessage.id,
|
|
content: fullReply,
|
|
retryVariants,
|
|
retryActiveIndex,
|
|
retryRetriesUsed,
|
|
retryPromptMessageId: promptMessageId
|
|
});
|
|
touchChatSessionStmt.run(chatId, user);
|
|
return res.json({message: formatChatMessage(getChatMessageByIdStmt.get(targetMessage.id, chatId)), promptMessageId});
|
|
} catch (err) {
|
|
console.error("Retry error:", err);
|
|
return res.status(500).json({error: "Failed to regenerate message"});
|
|
}
|
|
});
|
|
|
|
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}`));
|