implement an entire base refacotr and alot of styling overhauls and remakes.

This commit is contained in:
2026-04-14 23:21:01 +02:00
parent 554ad494b0
commit feaa2e42c4
32 changed files with 6031 additions and 2638 deletions
+123
View File
@@ -0,0 +1,123 @@
import fs from "fs";
import {performance} from "perf_hooks";
import express from "express";
import session from "express-session";
import FileStoreFactory from "session-file-store";
import {createConfig} from "./config.js";
import {createDatabase} from "./db/index.js";
import {createRepositories} from "./db/repositories.js";
import {createRateLimiter} from "./middleware/rateLimit.js";
import {createModelService} from "./services/modelService.js";
import {createChatService} from "./services/chatService.js";
import {createAuthRouter} from "./routes/auth.js";
import {createChatsRouter} from "./routes/chats.js";
import {createPagesRouter} from "./routes/pages.js";
import {createPersonasRouter} from "./routes/personas.js";
import {createSettingsRouter} from "./routes/settings.js";
import {createSystemRouter} from "./routes/system.js";
export function createApp(options = {}) {
const config = createConfig(options.config);
const app = express();
if (config.dbPath !== ":memory:") {
fs.mkdirSync(config.sessionsDir, {recursive: true});
}
let last = performance.now();
const lagMonitor = 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);
lagMonitor.unref();
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();
});
const db = createDatabase(config);
const repositories = createRepositories(db, config);
const modelService = options.modelService || createModelService(config, options.modelOverrides);
const chatService = createChatService(repositories, modelService, config);
const FileStore = FileStoreFactory(session);
const authRateLimiters = [
createRateLimiter({
windowMs: config.authRateLimitWindowMs,
max: config.authRateLimitMaxPerIp,
keyGenerator: (req) => `auth:ip:${req.ip}`,
message: "Too many authentication attempts from this IP. Please try again later."
}),
createRateLimiter({
windowMs: config.authRateLimitWindowMs,
max: config.authRateLimitMaxPerAccount,
keyGenerator: (req) => {
const username = String(req.body?.username || "").trim().toLowerCase();
return username ? `auth:user:${username}` : null;
},
message: "Too many authentication attempts for this account. Please try again later."
})
];
const chatRateLimiters = [
createRateLimiter({
windowMs: config.chatRateLimitWindowMs,
max: config.chatRateLimitMaxPerIp,
keyGenerator: (req) => `chat:ip:${req.ip}`,
message: "Too many chat requests from this IP. Please slow down."
}),
createRateLimiter({
windowMs: config.chatRateLimitWindowMs,
max: config.chatRateLimitMaxPerAccount,
keyGenerator: (req) => req.session.user ? `chat:user:${req.session.user}` : null,
message: "Too many chat requests for this account. Please slow down."
})
];
app.use(express.json({limit: "1mb"}));
app.use(express.static(config.publicDir));
app.use(session({
store: config.testMode ? undefined : new FileStore({path: config.sessionsDir, reapInterval: 3600}),
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
secure: config.cookieSecure,
httpOnly: true,
sameSite: "lax",
maxAge: config.sessionMaxAgeMs
}
}));
app.locals.config = config;
app.locals.db = db;
app.locals.repositories = repositories;
app.locals.modelService = modelService;
app.use(createAuthRouter({repositories, authRateLimiters}));
app.use(createSettingsRouter({repositories}));
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}));
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
app.use(createSystemRouter({modelService}));
app.use(createPagesRouter(config));
return app;
}
export function startServer(options = {}) {
const app = createApp(options);
const {port} = app.locals.config;
const server = app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
return {app, server};
}
+46
View File
@@ -0,0 +1,46 @@
import path from "path";
const bool = (value, fallback = false) => {
if (value == null || value === "") return fallback;
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
};
const int = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) ? parsed : fallback;
};
export function createConfig(overrides = {}) {
const rootDir = overrides.rootDir || process.cwd();
const dataDir = overrides.dataDir || path.resolve(rootDir, "data");
const sessionsDir = overrides.sessionsDir || path.resolve(rootDir, "sessions");
const config = {
rootDir,
publicDir: path.resolve(rootDir, "public"),
dataDir,
sessionsDir,
port: int(process.env.PORT, 3000),
dbPath: process.env.DB_PATH || path.resolve(dataDir, "app.db"),
sessionSecret: process.env.SESSION_SECRET || "change-me-session-secret",
cookieSecure: bool(process.env.COOKIE_SECURE, false),
sessionMaxAgeMs: int(process.env.SESSION_MAX_AGE_MS, 7 * 24 * 60 * 60 * 1000),
chatLimit: int(process.env.CHAT_LIMIT, 10),
chatHistoryLimit: int(process.env.CHAT_HISTORY_LIMIT, 20),
modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api",
modelRequestTimeoutMs: int(process.env.MODEL_REQUEST_TIMEOUT_MS, 15000),
modelListTimeoutMs: int(process.env.MODEL_LIST_TIMEOUT_MS, 4000),
modelUnloadAfterMs: int(process.env.MODEL_UNLOAD_AFTER_MS, 30 * 1000),
modelsCacheTtlMs: int(process.env.MODELS_CACHE_TTL_MS, 5 * 60 * 1000),
authRateLimitWindowMs: int(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000),
authRateLimitMaxPerIp: int(process.env.AUTH_RATE_LIMIT_MAX_PER_IP, 20),
authRateLimitMaxPerAccount: int(process.env.AUTH_RATE_LIMIT_MAX_PER_ACCOUNT, 10),
chatRateLimitWindowMs: int(process.env.CHAT_RATE_LIMIT_WINDOW_MS, 60 * 1000),
chatRateLimitMaxPerIp: int(process.env.CHAT_RATE_LIMIT_MAX_PER_IP, 60),
chatRateLimitMaxPerAccount: int(process.env.CHAT_RATE_LIMIT_MAX_PER_ACCOUNT, 30),
testMode: bool(process.env.TEST_MODE, process.env.NODE_ENV === "test"),
dropLegacyChats: bool(process.env.DROP_LEGACY_CHATS, true)
};
return {...config, ...overrides};
}
+14
View File
@@ -0,0 +1,14 @@
import fs from "fs";
import Database from "better-sqlite3";
import {initializeSchema, runMigrations} from "./schema.js";
export function createDatabase(config) {
if (config.dbPath !== ":memory:") {
fs.mkdirSync(config.dataDir, {recursive: true});
}
const db = new Database(config.dbPath);
initializeSchema(db);
runMigrations(db, config);
return db;
}
+910
View File
@@ -0,0 +1,910 @@
const parseJsonArray = (value) => {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const buildPersonaSnapshot = (persona) => JSON.stringify({
name: persona.name,
pronouns: persona.pronouns,
appearance: persona.appearance,
background: persona.background,
details: persona.details,
example_dialogues: persona.example_dialogues,
persona_type: persona.persona_type,
source_market_id: persona.source_market_id ?? null,
source_creator_username: persona.source_creator_username ?? null
});
const MARKET_SORT_ORDERS = {
best: "pm.favorite_count DESC, rating_average DESC, pm.usage_count DESC, datetime(pm.created_at) DESC, pm.id DESC",
newest: "datetime(pm.created_at) DESC, pm.id DESC",
most_favorited: "pm.favorite_count DESC, rating_average DESC, pm.usage_count DESC, datetime(pm.created_at) DESC, pm.id DESC",
most_popular: "pm.usage_count DESC, pm.favorite_count DESC, rating_average DESC, datetime(pm.created_at) DESC, pm.id DESC",
top_rated: "rating_average DESC, pm.rating_count DESC, pm.favorite_count DESC, datetime(pm.created_at) DESC, pm.id DESC",
alphabetical: "LOWER(pm.name) ASC, datetime(pm.created_at) DESC, pm.id DESC"
};
function normalizeMarketSort(sort) {
const normalized = String(sort || "best").trim().toLowerCase().replace(/-/g, "_");
return MARKET_SORT_ORDERS[normalized] ? normalized : "best";
}
function normalizeMarketPersonaType(personaType) {
return ["assistant", "user"].includes(personaType) ? personaType : null;
}
export function createRepositories(db, config) {
const statements = {
insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"),
getUser: db.prepare("SELECT username, password FROM users WHERE username = ?"),
updateUsername: db.prepare("UPDATE users SET username = ? WHERE username = ?"),
updatePassword: db.prepare("UPDATE users SET password = ? WHERE username = ?"),
insertChatSession: db.prepare(
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id, folder_name, is_pinned, archived_at, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
),
listChatSessions: 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.folder_name,
cs.is_pinned,
cs.archived_at,
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 cs.is_pinned DESC, datetime(cs.updated_at) DESC
`),
countChatSessions: db.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE username = ?"),
getChatSession: 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.folder_name,
cs.is_pinned,
cs.archived_at,
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.id = ?
AND cs.username = ?
`),
getChatSessionByParticipants: 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.folder_name,
cs.is_pinned,
cs.archived_at,
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 = ?
AND cs.assistant_persona_id = ?
AND ((cs.user_persona_id IS NULL AND ? IS NULL) OR cs.user_persona_id = ?)
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
LIMIT 1
`),
updateChatSessionTitle: db.prepare("UPDATE chat_sessions SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatSessionScene: db.prepare("UPDATE chat_sessions SET scenario_prompt = ?, scenario_summary = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatSessionOrganization: db.prepare(`
UPDATE chat_sessions
SET folder_name = ?,
is_pinned = ?,
archived_at = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND username = ?
`),
touchChatSession: db.prepare("UPDATE chat_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
deleteChatSession: db.prepare("DELETE FROM chat_sessions WHERE id = ? AND username = ?"),
clearAssistantPersonaFromChats: db.prepare(`
UPDATE chat_sessions
SET assistant_persona_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?
AND assistant_persona_id = ?
`),
clearUserPersonaFromChats: db.prepare(`
UPDATE chat_sessions
SET user_persona_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?
AND user_persona_id = ?
`),
insertChatMessage: db.prepare("INSERT INTO chat_messages (chat_id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
getChatMessages: db.prepare(`
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
ORDER BY id
`),
getRecentChatMessages: db.prepare(`
SELECT role, content
FROM chat_messages
WHERE chat_id = ?
ORDER BY id DESC
LIMIT ?
`),
getRecentChatMessagesUpToId: db.prepare(`
SELECT role, content
FROM chat_messages
WHERE chat_id = ?
AND id <= ?
ORDER BY id DESC
LIMIT ?
`),
getChatMessageById: db.prepare(`
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, created_at
FROM chat_messages
WHERE id = ?
AND chat_id = ?
`),
getChatMessageByIndex: db.prepare(`
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
ORDER BY id
LIMIT 1 OFFSET ?
`),
getLatestChatMessage: db.prepare(`
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
ORDER BY id DESC
LIMIT 1
`),
getPreviousUserMessage: db.prepare(`
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
AND role = 'user'
AND id < ?
ORDER BY id DESC
LIMIT 1
`),
updateChatMessage: db.prepare("UPDATE chat_messages SET content = ? WHERE id = ? AND chat_id = ?"),
updateChatMessageWithRetryState: db.prepare("UPDATE chat_messages SET content = ?, retry_variants = ?, retry_active_index = ?, retry_retries_used = ?, retry_prompt_message_id = ? WHERE id = ? AND chat_id = ?"),
deleteChatMessage: db.prepare("DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"),
deleteChatMessages: db.prepare("DELETE FROM chat_messages WHERE chat_id = ?"),
searchChats: db.prepare(`
SELECT DISTINCT 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.folder_name,
cs.is_pinned,
cs.archived_at,
cs.scenario_prompt,
cs.scenario_summary,
cs.created_at,
cs.updated_at,
cm.content AS matched_content
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
LEFT JOIN chat_messages cm ON cm.chat_id = cs.id
WHERE cs.username = ?
AND (
LOWER(CASE
WHEN cs.assistant_persona_id IS NOT NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END) LIKE ?
OR LOWER(COALESCE(cm.content, '')) LIKE ?
)
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
`),
listPersonasByType: 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
`),
listAllPersonas: db.prepare(`
SELECT id, username, name, pronouns, appearance, background, details, example_dialogues, persona_type,
source_market_id, source_creator_username, created_at, updated_at
FROM personas
WHERE username = ?
ORDER BY datetime(updated_at) DESC
`),
getPersona: 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 id = ?
AND username = ?
`),
insertPersona: db.prepare("INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
insertMarketPersona: db.prepare("INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type, source_market_id, source_creator_username) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
getPersonaBySourceMarket: db.prepare(`
SELECT id
FROM personas
WHERE username = ?
AND source_market_id = ?
`),
updatePersona: db.prepare(`
UPDATE personas
SET name = ?,
pronouns = ?,
appearance = ?,
background = ?,
details = ?,
example_dialogues = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND username = ?
`),
deletePersona: db.prepare("DELETE FROM personas WHERE id = ? AND username = ?"),
getNextPersonaVersionNumber: db.prepare("SELECT COALESCE(MAX(version_number), 0) + 1 AS nextVersion FROM persona_versions WHERE persona_id = ?"),
insertPersonaVersion: db.prepare("INSERT INTO persona_versions (persona_id, username, version_number, snapshot_json) VALUES (?, ?, ?, ?)"),
listPersonaVersions: db.prepare(`
SELECT id, persona_id, username, version_number, snapshot_json, created_at
FROM persona_versions
WHERE persona_id = ?
AND username = ?
ORDER BY version_number DESC
`),
getPersonaVersion: db.prepare(`
SELECT id, persona_id, username, version_number, snapshot_json, created_at
FROM persona_versions
WHERE id = ?
AND persona_id = ?
AND username = ?
`),
getActivePersonaId: db.prepare("SELECT active_persona_id, active_user_persona_id FROM user_settings WHERE username = ?"),
setActiveUserPersona: 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
`),
getActiveUserPersona: 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 = ?
`),
getAssistantPersonaForChat: 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 = ?
`),
getUserPersonaForChat: 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 = ?
`),
listMarketPersonas: db.prepare(`
SELECT pm.id, pm.persona_id, pm.creator_username, pm.name, pm.pronouns, pm.appearance, pm.background, pm.details,
pm.example_dialogues, pm.persona_type, pm.tags, pm.usage_count, pm.favorite_count, pm.rating_total, pm.rating_count,
pm.moderation_status, pm.moderation_notes, pm.created_at, pm.updated_at, pm.soft_deleted_at,
CASE WHEN pm.rating_count > 0 THEN CAST(pm.rating_total AS REAL) / pm.rating_count ELSE 0 END AS rating_average
FROM persona_market pm
WHERE pm.soft_deleted_at IS NULL
AND pm.moderation_status != 'rejected'
ORDER BY pm.favorite_count DESC, rating_average DESC, datetime(pm.updated_at) DESC
`),
listMarketPersonasAdmin: db.prepare(`
SELECT pm.id, pm.persona_id, pm.creator_username, pm.name, pm.pronouns, pm.appearance, pm.background, pm.details,
pm.example_dialogues, pm.persona_type, pm.tags, pm.usage_count, pm.favorite_count, pm.rating_total, pm.rating_count,
pm.moderation_status, pm.moderation_notes, pm.created_at, pm.updated_at, pm.soft_deleted_at,
CASE WHEN pm.rating_count > 0 THEN CAST(pm.rating_total AS REAL) / pm.rating_count ELSE 0 END AS rating_average
FROM persona_market pm
ORDER BY datetime(pm.updated_at) DESC
`),
getMarketPersona: db.prepare(`
SELECT pm.id, pm.persona_id, pm.creator_username, pm.name, pm.pronouns, pm.appearance, pm.background, pm.details,
pm.example_dialogues, pm.persona_type, pm.tags, pm.usage_count, pm.favorite_count, pm.rating_total, pm.rating_count,
pm.moderation_status, pm.moderation_notes, pm.soft_deleted_at,
CASE WHEN pm.rating_count > 0 THEN CAST(pm.rating_total AS REAL) / pm.rating_count ELSE 0 END AS rating_average
FROM persona_market pm
WHERE pm.id = ?
`),
getMarketPersonaByPersonaId: db.prepare(`
SELECT id, persona_id, creator_username, name, pronouns, appearance, background, details,
example_dialogues, persona_type, tags, usage_count, favorite_count, rating_total, rating_count,
moderation_status, moderation_notes, soft_deleted_at
FROM persona_market
WHERE persona_id = ?
AND creator_username = ?
`),
upsertMarketPersona: db.prepare(`
INSERT INTO persona_market (persona_id, creator_username, name, pronouns, appearance, background, details, example_dialogues, persona_type, tags)
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,
tags = excluded.tags,
moderation_status = 'approved',
moderation_notes = NULL,
soft_deleted_at = NULL,
updated_at = CURRENT_TIMESTAMP
`),
listPublishedPersonaIds: db.prepare("SELECT persona_id FROM persona_market WHERE creator_username = ? AND soft_deleted_at IS NULL"),
insertMarketFavorite: db.prepare("INSERT OR IGNORE INTO persona_market_favorites (market_id, username) VALUES (?, ?)"),
deleteMarketFavorite: db.prepare("DELETE FROM persona_market_favorites WHERE market_id = ? AND username = ?"),
isMarketFavorite: db.prepare("SELECT 1 AS favorite FROM persona_market_favorites WHERE market_id = ? AND username = ?"),
recountMarketFavorites: db.prepare("UPDATE persona_market SET favorite_count = (SELECT COUNT(*) FROM persona_market_favorites WHERE market_id = ?) WHERE id = ?"),
upsertMarketRating: db.prepare(`
INSERT INTO persona_market_ratings (market_id, username, rating)
VALUES (?, ?, ?)
ON CONFLICT(market_id, username) DO UPDATE SET
rating = excluded.rating,
updated_at = CURRENT_TIMESTAMP
`),
recountMarketRatings: db.prepare(`
UPDATE persona_market
SET rating_total = COALESCE((SELECT SUM(rating) FROM persona_market_ratings WHERE market_id = ?), 0),
rating_count = COALESCE((SELECT COUNT(*) FROM persona_market_ratings WHERE market_id = ?), 0)
WHERE id = ?
`),
insertMarketReport: db.prepare("INSERT INTO persona_market_reports (market_id, reporter_username, reason, details) VALUES (?, ?, ?, ?)"),
listMarketReports: db.prepare(`
SELECT id, market_id, reporter_username, reason, details, status, created_at, updated_at
FROM persona_market_reports
ORDER BY CASE WHEN status = 'open' THEN 0 ELSE 1 END, datetime(created_at) DESC
`),
updateMarketReportStatus: db.prepare("UPDATE persona_market_reports SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"),
updateMarketModeration: db.prepare(`
UPDATE persona_market
SET moderation_status = ?,
moderation_notes = ?,
soft_deleted_at = CASE WHEN ? THEN COALESCE(soft_deleted_at, CURRENT_TIMESTAMP) ELSE NULL END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`),
insertPromptTemplate: db.prepare(`
INSERT INTO prompt_templates (username, persona_id, name, description, category, prompt_text, starter_text, is_shared)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`),
getPromptTemplate: db.prepare(`
SELECT id, username, persona_id, name, description, category, prompt_text, starter_text, is_shared, created_at, updated_at
FROM prompt_templates
WHERE id = ?
`),
updatePromptTemplate: db.prepare(`
UPDATE prompt_templates
SET name = ?,
description = ?,
category = ?,
prompt_text = ?,
starter_text = ?,
is_shared = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND (username = ? OR (username IS NULL AND ? = 'system'))
`),
deletePromptTemplate: db.prepare("DELETE FROM prompt_templates WHERE id = ? AND username = ?"),
listPromptTemplates: db.prepare(`
SELECT id, username, persona_id, name, description, category, prompt_text, starter_text, is_shared, created_at, updated_at
FROM prompt_templates
WHERE username = ?
OR (is_shared = 1)
OR (username IS NULL)
ORDER BY is_shared DESC, datetime(updated_at) DESC
`),
listPersonaPromptTemplates: db.prepare(`
SELECT id, username, persona_id, name, description, category, prompt_text, starter_text, is_shared, created_at, updated_at
FROM prompt_templates
WHERE (persona_id = ? AND (username = ? OR username IS NULL OR is_shared = 1))
OR (persona_id IS NULL AND (username = ? OR username IS NULL OR is_shared = 1))
ORDER BY is_shared DESC, datetime(updated_at) DESC
`),
getChatMessageCountForUser: db.prepare(`
SELECT COUNT(*) as count
FROM chat_messages cm
JOIN chat_sessions cs ON cs.id = cm.chat_id
WHERE cs.username = ?
`),
getPersonaCountForUser: db.prepare("SELECT COUNT(*) as count FROM personas WHERE username = ?"),
getPublishedPersonaCountForUser: db.prepare("SELECT COUNT(*) as count FROM persona_market WHERE creator_username = ? AND soft_deleted_at IS NULL"),
getRecentActivity: db.prepare(`
SELECT cs.id AS chat_id,
cs.title,
MAX(cm.created_at) AS last_message_at,
COUNT(cm.id) AS message_count
FROM chat_sessions cs
LEFT JOIN chat_messages cm ON cm.chat_id = cs.id
WHERE cs.username = ?
GROUP BY cs.id
ORDER BY datetime(MAX(COALESCE(cm.created_at, cs.updated_at))) DESC
LIMIT 5
`),
getMostUsedPersonas: db.prepare(`
SELECT p.id, p.name, COUNT(cs.id) AS usage_count
FROM personas p
LEFT JOIN chat_sessions cs ON cs.assistant_persona_id = p.id OR cs.user_persona_id = p.id
WHERE p.username = ?
GROUP BY p.id
ORDER BY usage_count DESC, datetime(p.updated_at) DESC
LIMIT 5
`),
getAverageChatLength: db.prepare(`
SELECT AVG(message_count) AS average_length
FROM (
SELECT COUNT(cm.id) AS message_count
FROM chat_sessions cs
LEFT JOIN chat_messages cm ON cm.chat_id = cs.id
WHERE cs.username = ?
GROUP BY cs.id
)
`),
getFavoriteModel: db.prepare(`
SELECT model_name, COUNT(*) AS usage_count
FROM chat_messages cm
JOIN chat_sessions cs ON cs.id = cm.chat_id
WHERE cs.username = ?
AND cm.model_name IS NOT NULL
GROUP BY model_name
ORDER BY usage_count DESC
LIMIT 1
`),
getCollectedPersonaCount: db.prepare("SELECT COUNT(*) AS count FROM personas WHERE username = ? AND source_market_id IS NOT NULL"),
getPublishedPersonaUsage: db.prepare(`
SELECT COUNT(*) AS published_count, COALESCE(SUM(usage_count), 0) AS usage_total
FROM persona_market
WHERE creator_username = ?
AND soft_deleted_at IS NULL
`),
updateChatSessionsUsername: db.prepare("UPDATE chat_sessions SET username = ? WHERE username = ?"),
updatePersonasUsername: db.prepare("UPDATE personas SET username = ? WHERE username = ?"),
updatePersonasSourceCreator: db.prepare("UPDATE personas SET source_creator_username = ? WHERE source_creator_username = ?"),
updateUserSettingsUsername: db.prepare("UPDATE user_settings SET username = ? WHERE username = ?"),
updatePersonaMarketCreator: db.prepare("UPDATE persona_market SET creator_username = ? WHERE creator_username = ?"),
updatePersonaVersionsUsername: db.prepare("UPDATE persona_versions SET username = ? WHERE username = ?"),
updatePromptTemplatesUsername: db.prepare("UPDATE prompt_templates SET username = ? WHERE username = ?"),
updateMarketFavoritesUsername: db.prepare("UPDATE persona_market_favorites SET username = ? WHERE username = ?"),
updateMarketRatingsUsername: db.prepare("UPDATE persona_market_ratings SET username = ? WHERE username = ?"),
updateMarketReportsUsername: db.prepare("UPDATE persona_market_reports SET reporter_username = ? WHERE reporter_username = ?"),
deleteMarketPersonaByPersonaId: db.prepare("DELETE FROM persona_market WHERE persona_id = ? AND creator_username = ?"),
incrementMarketUsageCount: db.prepare("UPDATE persona_market SET usage_count = usage_count + 1 WHERE id = ?")
};
const renameUserTransaction = db.transaction((currentUsername, nextUsername) => {
statements.updateUsername.run(nextUsername, currentUsername);
statements.updateChatSessionsUsername.run(nextUsername, currentUsername);
statements.updatePersonasUsername.run(nextUsername, currentUsername);
statements.updatePersonasSourceCreator.run(nextUsername, currentUsername);
statements.updateUserSettingsUsername.run(nextUsername, currentUsername);
statements.updatePersonaMarketCreator.run(nextUsername, currentUsername);
statements.updatePersonaVersionsUsername.run(nextUsername, currentUsername);
statements.updatePromptTemplatesUsername.run(nextUsername, currentUsername);
statements.updateMarketFavoritesUsername.run(nextUsername, currentUsername);
statements.updateMarketRatingsUsername.run(nextUsername, currentUsername);
statements.updateMarketReportsUsername.run(nextUsername, currentUsername);
});
const savePersonaVersion = db.transaction((personaId, username) => {
const persona = statements.getPersona.get(personaId, username);
if (!persona) return null;
const versionNumber = statements.getNextPersonaVersionNumber.get(personaId).nextVersion;
statements.insertPersonaVersion.run(personaId, username, versionNumber, buildPersonaSnapshot(persona));
return versionNumber;
});
const clonePersonaTransaction = db.transaction((personaId, username) => {
const source = statements.getPersona.get(personaId, username);
if (!source) return null;
const cloneId = statements.insertPersona.run(
username,
`${source.name} Copy`,
source.pronouns,
source.appearance,
source.background,
source.details,
source.example_dialogues,
source.persona_type
).lastInsertRowid;
const clone = statements.getPersona.get(cloneId, username);
statements.insertPersonaVersion.run(cloneId, username, 1, buildPersonaSnapshot(clone));
return clone;
});
const importWorkspaceTransaction = db.transaction((username, workspace) => {
const imported = {personas: 0, chats: 0, templates: 0};
for (const persona of workspace.personas || []) {
const personaId = statements.insertPersona.run(
username,
persona.name,
persona.pronouns ?? null,
persona.appearance ?? null,
persona.background ?? null,
persona.details ?? null,
persona.example_dialogues ?? null,
persona.persona_type || "assistant"
).lastInsertRowid;
statements.insertPersonaVersion.run(personaId, username, 1, JSON.stringify(persona));
imported.personas += 1;
}
for (const template of workspace.templates || []) {
statements.insertPromptTemplate.run(
username,
null,
template.name,
template.description ?? null,
template.category ?? null,
template.prompt_text,
template.starter_text ?? null,
template.is_shared ? 1 : 0
);
imported.templates += 1;
}
for (const chat of workspace.chats || []) {
const chatId = statements.insertChatSession.run(
username,
chat.stored_title || chat.title || "Imported chat",
null,
null,
chat.folder_name ?? null,
chat.is_pinned ? 1 : 0,
chat.archived_at ?? null,
chat.scenario_prompt ?? null,
chat.scenario_summary ?? null
).lastInsertRowid;
for (const message of chat.messages || []) {
statements.insertChatMessage.run(
chatId,
message.role,
message.content,
message.model_name ?? null,
JSON.stringify(message.retryVariants || [message.content || ""]),
Number(message.retryActiveIndex || 0),
Number(message.retryRetriesUsed || 0),
message.retryPromptMessageId ?? null
);
}
imported.chats += 1;
}
return imported;
});
return {
getUser: (username) => statements.getUser.get(username),
insertUser: (username, passwordHash) => statements.insertUser.run(username, passwordHash),
updatePassword: (passwordHash, username) => statements.updatePassword.run(passwordHash, username),
renameUser: (currentUsername, nextUsername) => renameUserTransaction(currentUsername, nextUsername),
listChats: (username) => statements.listChatSessions.all(username),
searchChats(username, query) {
const like = `%${String(query || "").trim().toLowerCase()}%`;
return statements.searchChats.all(username, like, like);
},
countChats: (username) => statements.countChatSessions.get(username).count,
getChat: (chatId, username) => statements.getChatSession.get(chatId, username),
getChatByParticipants: (username, assistantPersonaId, userPersonaId) => statements.getChatSessionByParticipants.get(username, assistantPersonaId, userPersonaId, userPersonaId),
createChat(username, title, assistantPersonaId = null, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null, organization = {}) {
const chatId = statements.insertChatSession.run(
username,
title,
assistantPersonaId,
userPersonaId,
organization.folderName ?? null,
organization.isPinned ? 1 : 0,
organization.archivedAt ?? null,
scenarioPrompt,
scenarioSummary
).lastInsertRowid;
return statements.getChatSession.get(chatId, username);
},
updateChatTitle: (chatId, username, title) => statements.updateChatSessionTitle.run(title, chatId, username),
updateChatScene: (chatId, username, scenarioPrompt, scenarioSummary) => statements.updateChatSessionScene.run(scenarioPrompt, scenarioSummary, chatId, username),
updateChatOrganization(chatId, username, {folderName = null, isPinned = false, archivedAt = null}) {
return statements.updateChatSessionOrganization.run(folderName, isPinned ? 1 : 0, archivedAt, chatId, username);
},
touchChat: (chatId, username) => statements.touchChatSession.run(chatId, username),
deleteChat: (chatId, username) => statements.deleteChatSession.run(chatId, username),
listChatMessages: (chatId) => statements.getChatMessages.all(chatId),
getRecentChatMessages: (chatId) => statements.getRecentChatMessages.all(chatId, config.chatHistoryLimit),
getRecentChatMessagesUpToId: (chatId, messageId) => statements.getRecentChatMessagesUpToId.all(chatId, messageId, config.chatHistoryLimit),
getChatMessage: (chatId, messageId) => statements.getChatMessageById.get(messageId, chatId),
getChatMessageByIndex: (chatId, index) => statements.getChatMessageByIndex.get(chatId, index),
getLatestChatMessage: (chatId) => statements.getLatestChatMessage.get(chatId),
getPreviousUserMessage: (chatId, messageId) => statements.getPreviousUserMessage.get(chatId, messageId),
insertChatMessage(chatId, role, content, retryState = null, modelName = null) {
return statements.insertChatMessage.run(
chatId,
role,
String(content || ""),
modelName || null,
retryState ? JSON.stringify(retryState.retryVariants) : null,
retryState?.retryActiveIndex ?? 0,
retryState?.retryRetriesUsed ?? 0,
retryState?.retryPromptMessageId ?? null
);
},
updateChatMessage: (chatId, messageId, content) => statements.updateChatMessage.run(content, messageId, chatId),
updateChatMessageRetryState: (chatId, messageId, payload) => statements.updateChatMessageWithRetryState.run(
payload.content,
JSON.stringify(payload.retryVariants),
payload.retryActiveIndex,
payload.retryRetriesUsed,
payload.retryPromptMessageId ?? null,
messageId,
chatId
),
deleteChatMessage: (chatId, messageId) => statements.deleteChatMessage.run(messageId, chatId),
clearChatMessages: (chatId) => statements.deleteChatMessages.run(chatId),
listPersonasByType: (username, personaType) => statements.listPersonasByType.all(username, personaType),
listAllPersonas: (username) => statements.listAllPersonas.all(username),
getPersona: (personaId, username) => statements.getPersona.get(personaId, username),
createPersona(username, payload, personaType) {
const personaId = statements.insertPersona.run(
username,
payload.name,
payload.pronouns,
payload.appearance,
payload.background,
payload.details,
payload.exampleDialogues,
personaType
).lastInsertRowid;
const persona = statements.getPersona.get(personaId, username);
statements.insertPersonaVersion.run(personaId, username, 1, buildPersonaSnapshot(persona));
return persona;
},
updatePersona(personaId, username, payload) {
savePersonaVersion(personaId, username);
return statements.updatePersona.run(
payload.name,
payload.pronouns,
payload.appearance,
payload.background,
payload.details,
payload.exampleDialogues,
personaId,
username
);
},
restorePersonaVersion(personaId, versionId, username) {
const version = statements.getPersonaVersion.get(versionId, personaId, username);
if (!version) return null;
savePersonaVersion(personaId, username);
const snapshot = JSON.parse(version.snapshot_json);
statements.updatePersona.run(
snapshot.name,
snapshot.pronouns ?? null,
snapshot.appearance ?? null,
snapshot.background ?? null,
snapshot.details ?? null,
snapshot.example_dialogues ?? null,
personaId,
username
);
return statements.getPersona.get(personaId, username);
},
listPersonaVersions(personaId, username) {
return statements.listPersonaVersions.all(personaId, username).map((version) => ({
...version,
snapshot: JSON.parse(version.snapshot_json)
}));
},
clonePersona: (personaId, username) => clonePersonaTransaction(personaId, username),
deletePersona: (personaId, username) => statements.deletePersona.run(personaId, username),
clearPersonaReferences(username, personaId) {
statements.clearAssistantPersonaFromChats.run(username, personaId);
statements.clearUserPersonaFromChats.run(username, personaId);
statements.deleteMarketPersonaByPersonaId.run(personaId, username);
},
getActivePersonaIds: (username) => statements.getActivePersonaId.get(username) || {},
setActiveUserPersona: (username, personaId) => statements.setActiveUserPersona.run(username, personaId),
getActiveUserPersona: (username) => statements.getActiveUserPersona.get(username),
getAssistantPersonaForChat: (chatId, username) => statements.getAssistantPersonaForChat.get(chatId, username),
getUserPersonaForChat: (chatId, username) => statements.getUserPersonaForChat.get(chatId, username),
listMarketPersonas({admin = false, username = null, personaType = null, sort = "best"} = {}) {
const normalizedPersonaType = normalizeMarketPersonaType(personaType);
const normalizedSort = normalizeMarketSort(sort);
const params = [username];
const filters = [];
if (!admin) {
filters.push("pm.soft_deleted_at IS NULL");
filters.push("pm.moderation_status != 'rejected'");
}
if (normalizedPersonaType) {
filters.push("pm.persona_type = ?");
params.push(normalizedPersonaType);
}
const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
const query = db.prepare(`
SELECT pm.id, pm.persona_id, pm.creator_username, pm.name, pm.pronouns, pm.appearance, pm.background, pm.details,
pm.example_dialogues, pm.persona_type, pm.tags, pm.usage_count, pm.favorite_count, pm.rating_total, pm.rating_count,
pm.moderation_status, pm.moderation_notes, pm.created_at, pm.updated_at, pm.soft_deleted_at,
CASE WHEN pm.rating_count > 0 THEN CAST(pm.rating_total AS REAL) / pm.rating_count ELSE 0 END AS rating_average,
CASE WHEN mf.username IS NULL THEN 0 ELSE 1 END AS is_favorite
FROM persona_market pm
LEFT JOIN persona_market_favorites mf ON mf.market_id = pm.id AND mf.username = ?
${whereClause}
ORDER BY ${MARKET_SORT_ORDERS[normalizedSort]}
`);
return query.all(...params).map((persona) => ({
...persona,
is_favorite: Boolean(persona.is_favorite),
tags: parseJsonArray(persona.tags)
}));
},
getMarketPersona: (marketId) => {
const persona = statements.getMarketPersona.get(marketId);
return persona ? {...persona, tags: parseJsonArray(persona.tags)} : null;
},
getMarketPersonaByPersonaId: (personaId, username) => {
const persona = statements.getMarketPersonaByPersonaId.get(personaId, username);
return persona ? {...persona, tags: parseJsonArray(persona.tags)} : null;
},
upsertMarketPersona(personaId, username, persona, tags = []) {
statements.upsertMarketPersona.run(
personaId,
username,
persona.name,
persona.pronouns,
persona.appearance,
persona.background,
persona.details,
persona.example_dialogues,
persona.persona_type,
JSON.stringify(tags)
);
return this.getMarketPersonaByPersonaId(personaId, username);
},
listPublishedPersonaIds: (username) => statements.listPublishedPersonaIds.all(username).map((row) => row.persona_id),
getPersonaBySourceMarket: (username, marketId) => statements.getPersonaBySourceMarket.get(username, marketId),
collectMarketPersona(username, marketPersona) {
const personaId = statements.insertMarketPersona.run(
username,
marketPersona.name,
marketPersona.pronouns,
marketPersona.appearance,
marketPersona.background,
marketPersona.details,
marketPersona.example_dialogues,
marketPersona.persona_type,
marketPersona.id,
marketPersona.creator_username
).lastInsertRowid;
const persona = statements.getPersona.get(personaId, username);
statements.insertPersonaVersion.run(personaId, username, 1, buildPersonaSnapshot(persona));
return persona;
},
incrementMarketUsageCount: (marketId) => statements.incrementMarketUsageCount.run(marketId),
toggleMarketFavorite(marketId, username) {
const favorite = statements.isMarketFavorite.get(marketId, username);
if (favorite) statements.deleteMarketFavorite.run(marketId, username);
else statements.insertMarketFavorite.run(marketId, username);
statements.recountMarketFavorites.run(marketId, marketId);
return !favorite;
},
rateMarketPersona(marketId, username, rating) {
statements.upsertMarketRating.run(marketId, username, rating);
statements.recountMarketRatings.run(marketId, marketId, marketId);
return this.getMarketPersona(marketId);
},
reportMarketPersona: (marketId, username, reason, details) => statements.insertMarketReport.run(marketId, username, reason, details),
listMarketReports: () => statements.listMarketReports.all(),
updateMarketReportStatus: (reportId, status) => statements.updateMarketReportStatus.run(status, reportId),
updateMarketModeration: (marketId, {status, notes = null, softDelete = false}) => statements.updateMarketModeration.run(status, notes, softDelete ? 1 : 0, marketId),
deletePublishedPersona: (personaId, username) => statements.deleteMarketPersonaByPersonaId.run(personaId, username),
createPromptTemplate(username, payload) {
const id = statements.insertPromptTemplate.run(
username,
payload.personaId ?? null,
payload.name,
payload.description ?? null,
payload.category ?? null,
payload.promptText,
payload.starterText ?? null,
payload.isShared ? 1 : 0
).lastInsertRowid;
return statements.getPromptTemplate.get(id);
},
updatePromptTemplate(templateId, username, payload) {
return statements.updatePromptTemplate.run(
payload.name,
payload.description ?? null,
payload.category ?? null,
payload.promptText,
payload.starterText ?? null,
payload.isShared ? 1 : 0,
templateId,
username,
username
);
},
deletePromptTemplate: (templateId, username) => statements.deletePromptTemplate.run(templateId, username),
listPromptTemplates: (username) => statements.listPromptTemplates.all(username),
listPromptTemplatesForPersona: (username, personaId = null) => statements.listPersonaPromptTemplates.all(personaId, username, username),
getPromptTemplate: (templateId) => statements.getPromptTemplate.get(templateId),
getDashboardSummary(username) {
const favoriteModel = statements.getFavoriteModel.get(username);
const publishedUsage = statements.getPublishedPersonaUsage.get(username);
return {
chats: statements.countChatSessions.get(username).count,
messages: statements.getChatMessageCountForUser.get(username).count,
personas: statements.getPersonaCountForUser.get(username).count,
published: statements.getPublishedPersonaCountForUser.get(username).count,
marketPersonas: statements.listMarketPersonas.all().length,
mostUsedPersonas: statements.getMostUsedPersonas.all(username),
averageChatLength: Number(statements.getAverageChatLength.get(username)?.average_length || 0),
favoriteModel: favoriteModel?.model_name || null,
recentActivity: statements.getRecentActivity.all(username),
collectedPersonaCount: statements.getCollectedPersonaCount.get(username).count,
publishedPersonaPerformance: {
publishedCount: publishedUsage.published_count,
totalUses: publishedUsage.usage_total
}
};
},
exportWorkspace(username) {
return {
exportedAt: new Date().toISOString(),
chats: this.listChats(username).map((chat) => ({
...chat,
messages: this.listChatMessages(chat.id).map((message) => ({
...message,
retryVariants: parseJsonArray(message.retry_variants)
}))
})),
personas: this.listAllPersonas(username),
templates: this.listPromptTemplates(username)
};
},
importWorkspace: (username, workspace) => importWorkspaceTransaction(username, workspace)
};
}
+336
View File
@@ -0,0 +1,336 @@
export function initializeSchema(db) {
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 chat_sessions
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
title TEXT NOT NULL,
assistant_persona_id INTEGER,
user_persona_id INTEGER,
folder_name TEXT,
is_pinned INTEGER DEFAULT 0,
archived_at DATETIME,
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,
model_name TEXT,
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,
custom_fields TEXT,
persona_type TEXT DEFAULT 'assistant',
source_market_id INTEGER,
source_creator_username TEXT,
example_dialogues 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 (username) REFERENCES users (username) ON DELETE CASCADE,
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',
tags TEXT,
usage_count INTEGER DEFAULT 0,
favorite_count INTEGER DEFAULT 0,
rating_total INTEGER DEFAULT 0,
rating_count INTEGER DEFAULT 0,
moderation_status TEXT DEFAULT 'approved',
moderation_notes TEXT,
soft_deleted_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (persona_id, creator_username)
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_versions
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
persona_id INTEGER NOT NULL,
username TEXT NOT NULL,
version_number INTEGER NOT NULL,
snapshot_json TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (persona_id) REFERENCES personas (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_market_favorites
(
market_id INTEGER NOT NULL,
username TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (market_id, username),
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_market_ratings
(
market_id INTEGER NOT NULL,
username TEXT NOT NULL,
rating INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (market_id, username),
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_market_reports
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
market_id INTEGER NOT NULL,
reporter_username TEXT NOT NULL,
reason TEXT NOT NULL,
details TEXT,
status TEXT DEFAULT 'open',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS prompt_templates
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
persona_id INTEGER,
name TEXT NOT NULL,
description TEXT,
category TEXT,
prompt_text TEXT NOT NULL,
starter_text TEXT,
is_shared INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (persona_id) REFERENCES personas (id) ON DELETE CASCADE
)
`).run();
}
export function runMigrations(db, {dropLegacyChats = true} = {}) {
const ensureColumn = (tableName, columns, name, sql) => {
if (!columns.some((column) => column.name === name)) {
db.prepare(sql).run();
}
};
const personaColumns = db.prepare("PRAGMA table_info(personas)").all();
ensureColumn("personas", personaColumns, "custom_fields", "ALTER TABLE personas ADD COLUMN custom_fields TEXT");
ensureColumn("personas", personaColumns, "persona_type", "ALTER TABLE personas ADD COLUMN persona_type TEXT DEFAULT 'assistant'");
ensureColumn("personas", personaColumns, "source_market_id", "ALTER TABLE personas ADD COLUMN source_market_id INTEGER");
ensureColumn("personas", personaColumns, "source_creator_username", "ALTER TABLE personas ADD COLUMN source_creator_username TEXT");
ensureColumn("personas", personaColumns, "example_dialogues", "ALTER TABLE personas ADD COLUMN example_dialogues TEXT");
const userSettingsColumns = db.prepare("PRAGMA table_info(user_settings)").all();
ensureColumn("user_settings", userSettingsColumns, "active_user_persona_id", "ALTER TABLE user_settings ADD COLUMN active_user_persona_id INTEGER");
const marketColumns = db.prepare("PRAGMA table_info(persona_market)").all();
ensureColumn("persona_market", marketColumns, "persona_id", "ALTER TABLE persona_market ADD COLUMN persona_id INTEGER");
ensureColumn("persona_market", marketColumns, "usage_count", "ALTER TABLE persona_market ADD COLUMN usage_count INTEGER DEFAULT 0");
ensureColumn("persona_market", marketColumns, "example_dialogues", "ALTER TABLE persona_market ADD COLUMN example_dialogues TEXT");
ensureColumn("persona_market", marketColumns, "tags", "ALTER TABLE persona_market ADD COLUMN tags TEXT");
ensureColumn("persona_market", marketColumns, "favorite_count", "ALTER TABLE persona_market ADD COLUMN favorite_count INTEGER DEFAULT 0");
ensureColumn("persona_market", marketColumns, "rating_total", "ALTER TABLE persona_market ADD COLUMN rating_total INTEGER DEFAULT 0");
ensureColumn("persona_market", marketColumns, "rating_count", "ALTER TABLE persona_market ADD COLUMN rating_count INTEGER DEFAULT 0");
ensureColumn("persona_market", marketColumns, "moderation_status", "ALTER TABLE persona_market ADD COLUMN moderation_status TEXT DEFAULT 'approved'");
ensureColumn("persona_market", marketColumns, "moderation_notes", "ALTER TABLE persona_market ADD COLUMN moderation_notes TEXT");
ensureColumn("persona_market", marketColumns, "soft_deleted_at", "ALTER TABLE persona_market ADD COLUMN soft_deleted_at DATETIME");
db.prepare("UPDATE persona_market SET usage_count = 0 WHERE usage_count IS NULL").run();
db.prepare("UPDATE persona_market SET favorite_count = 0 WHERE favorite_count IS NULL").run();
db.prepare("UPDATE persona_market SET rating_total = 0 WHERE rating_total IS NULL").run();
db.prepare("UPDATE persona_market SET rating_count = 0 WHERE rating_count IS NULL").run();
const chatMessageColumns = db.prepare("PRAGMA table_info(chat_messages)").all();
ensureColumn("chat_messages", chatMessageColumns, "model_name", "ALTER TABLE chat_messages ADD COLUMN model_name TEXT");
ensureColumn("chat_messages", chatMessageColumns, "retry_variants", "ALTER TABLE chat_messages ADD COLUMN retry_variants TEXT");
ensureColumn("chat_messages", chatMessageColumns, "retry_active_index", "ALTER TABLE chat_messages ADD COLUMN retry_active_index INTEGER DEFAULT 0");
ensureColumn("chat_messages", chatMessageColumns, "retry_retries_used", "ALTER TABLE chat_messages ADD COLUMN retry_retries_used INTEGER DEFAULT 0");
ensureColumn("chat_messages", chatMessageColumns, "retry_prompt_message_id", "ALTER TABLE chat_messages ADD COLUMN retry_prompt_message_id INTEGER");
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();
ensureColumn("chat_sessions", chatSessionColumns, "assistant_persona_id", "ALTER TABLE chat_sessions ADD COLUMN assistant_persona_id INTEGER");
ensureColumn("chat_sessions", chatSessionColumns, "user_persona_id", "ALTER TABLE chat_sessions ADD COLUMN user_persona_id INTEGER");
ensureColumn("chat_sessions", chatSessionColumns, "folder_name", "ALTER TABLE chat_sessions ADD COLUMN folder_name TEXT");
ensureColumn("chat_sessions", chatSessionColumns, "is_pinned", "ALTER TABLE chat_sessions ADD COLUMN is_pinned INTEGER DEFAULT 0");
ensureColumn("chat_sessions", chatSessionColumns, "archived_at", "ALTER TABLE chat_sessions ADD COLUMN archived_at DATETIME");
ensureColumn("chat_sessions", chatSessionColumns, "scenario_prompt", "ALTER TABLE chat_sessions ADD COLUMN scenario_prompt TEXT");
ensureColumn("chat_sessions", chatSessionColumns, "scenario_summary", "ALTER TABLE chat_sessions ADD COLUMN scenario_summary TEXT");
db.prepare("UPDATE chat_sessions SET is_pinned = 0 WHERE is_pinned IS NULL").run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_versions
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
persona_id INTEGER NOT NULL,
username TEXT NOT NULL,
version_number INTEGER NOT NULL,
snapshot_json TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (persona_id) REFERENCES personas (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_market_favorites
(
market_id INTEGER NOT NULL,
username TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (market_id, username),
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_market_ratings
(
market_id INTEGER NOT NULL,
username TEXT NOT NULL,
rating INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (market_id, username),
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_market_reports
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
market_id INTEGER NOT NULL,
reporter_username TEXT NOT NULL,
reason TEXT NOT NULL,
details TEXT,
status TEXT DEFAULT 'open',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS prompt_templates
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
persona_id INTEGER,
name TEXT NOT NULL,
description TEXT,
category TEXT,
prompt_text TEXT NOT NULL,
starter_text TEXT,
is_shared INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (persona_id) REFERENCES personas (id) ON DELETE CASCADE
)
`).run();
const legacyChatsExist = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chats'").get();
if (!legacyChatsExist) {
return;
}
const migrationNeeded = db.prepare("SELECT COUNT(*) as count FROM chat_messages").get();
if (migrationNeeded.count === 0) {
const insertChatSessionStmt = db.prepare(
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?)"
);
const getLegacyUsersStmt = db.prepare("SELECT DISTINCT username FROM chats");
const getLegacyChatsStmt = db.prepare(`
SELECT role, content
FROM chats
WHERE username = ?
ORDER BY datetime(created_at)
`);
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 migrateLegacyChats = db.transaction(() => {
for (const {username} of getLegacyUsersStmt.all()) {
const chatId = insertChatSessionStmt.run(username, "Imported chat", null, null, null, null).lastInsertRowid;
for (const row of getLegacyChatsStmt.all(username)) {
insertChatMessageStmt.run(chatId, row.role, row.content, null, 0, 0, null);
}
}
});
migrateLegacyChats();
}
if (dropLegacyChats) {
db.prepare("DROP TABLE chats").run();
}
}
+6
View File
@@ -0,0 +1,6 @@
export function requireLogin(req, res, next) {
if (!req.session.user) {
return res.status(403).json({error: "Not logged in"});
}
return next();
}
+28
View File
@@ -0,0 +1,28 @@
function pruneBucket(bucket, windowMs, now) {
const cutoff = now - windowMs;
while (bucket.length && bucket[0] <= cutoff) {
bucket.shift();
}
}
export function createRateLimiter({windowMs, max, keyGenerator, message}) {
const hits = new Map();
return (req, res, next) => {
const key = keyGenerator(req);
if (!key) {
return next();
}
const now = Date.now();
const bucket = hits.get(key) || [];
pruneBucket(bucket, windowMs, now);
if (bucket.length >= max) {
return res.status(429).json({error: message});
}
bucket.push(now);
hits.set(key, bucket);
return next();
};
}
+10
View File
@@ -0,0 +1,10 @@
export function validateBody(validator) {
return (req, res, next) => {
const result = validator(req.body || {}, req);
if (result?.error) {
return res.status(400).json({error: result.error});
}
req.validatedBody = result?.value ?? req.body ?? {};
return next();
};
}
+48
View File
@@ -0,0 +1,48 @@
import express from "express";
import bcrypt from "bcrypt";
import {validateBody} from "../middleware/validate.js";
const credentialsValidator = (body) => {
const username = String(body?.username || "").trim();
const password = String(body?.password || "");
if (!username) return {error: "Username is required"};
if (username.length < 3) return {error: "Username must be at least 3 characters"};
if (!password) return {error: "Password is required"};
if (password.length < 6) return {error: "Password must be at least 6 characters"};
return {value: {username, password}};
};
export function createAuthRouter({repositories, authRateLimiters}) {
const router = express.Router();
router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), async (req, res) => {
const {username, password} = req.validatedBody;
if (repositories.getUser(username)) {
return res.status(400).json({error: "User already exists"});
}
const hashed = await bcrypt.hash(password, 10);
repositories.insertUser(username, hashed);
return res.json({message: "Registration successful"});
});
router.post("/login", ...authRateLimiters, validateBody(credentialsValidator), async (req, res) => {
const {username, password} = req.validatedBody;
const user = repositories.getUser(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;
return res.json({message: "Login successful"});
});
router.post("/logout", (req, res) => {
req.session.destroy(() => res.json({message: "Logged out"}));
});
router.get("/session", (req, res) => {
return res.json({user: req.session.user || null});
});
return router;
}
+334
View File
@@ -0,0 +1,334 @@
import express from "express";
import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js";
const createChatValidator = (body) => ({
value: {
title: String(body?.title || "New chat").trim() || "New chat",
assistantPersonaId: body?.assistantPersonaId ? Number(body.assistantPersonaId) : null
}
});
const messageValidator = (body) => {
const content = String(body?.content || "").trim();
if (!content) return {error: "Message content is required"};
return {value: {content, retryActiveIndex: body?.retryActiveIndex}};
};
const sendMessageValidator = (body) => {
const message = String(body?.message || "").trim();
const chatId = Number(body?.chatId);
if (!chatId) return {error: "chatId is required"};
if (!message) return {error: "message is required"};
return {value: {message, chatId, model: String(body?.model || "mistral:latest")}};
};
const organizationValidator = (body) => ({
value: {
folderName: String(body?.folderName || "").trim() || null,
isPinned: Boolean(body?.isPinned),
archived: Boolean(body?.archived)
}
});
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}) {
const router = express.Router();
router.use(requireLogin);
router.get("/models", async (req, res) => {
try {
return res.json(await modelService.listModels());
} catch (error) {
const mapped = modelService.mapError(error);
return res.status(mapped.status).json(mapped.body);
}
});
router.get("/chats", (req, res) => res.json({chats: repositories.listChats(req.session.user)}));
router.get("/chats/search", (req, res) => res.json({chats: repositories.searchChats(req.session.user, req.query.q || "")}));
router.post("/chats", validateBody(createChatValidator), (req, res) => {
const user = req.session.user;
const {assistantPersonaId, title} = req.validatedBody;
if (assistantPersonaId) {
const persona = repositories.getPersona(assistantPersonaId, user);
if (!persona || persona.persona_type !== "assistant") {
return res.status(404).json({error: "AI Character not found"});
}
const existingChat = repositories.getChatByParticipants(user, assistantPersonaId, null);
try {
const chat = chatService.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 ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
}
throw error;
}
}
if (repositories.countChats(user) >= config.chatLimit) {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
}
return res.json({chat: repositories.createChat(user, title), existing: false});
});
router.put("/chats/:id", validateBody((body) => {
const title = String(body?.title || "").trim();
return title ? {value: {title}} : {error: "Title is required"};
}), (req, res) => {
const chatId = Number(req.params.id);
const result = repositories.updateChatTitle(chatId, req.session.user, req.validatedBody.title);
if (result.changes === 0) return res.status(404).json({error: "Chat not found"});
return res.json({chat: {id: chatId, title: req.validatedBody.title}});
});
router.put("/chats/:id/organization", validateBody(organizationValidator), (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
repositories.updateChatOrganization(chatId, req.session.user, {
folderName: req.validatedBody.folderName,
isPinned: req.validatedBody.isPinned,
archivedAt: req.validatedBody.archived ? new Date().toISOString() : null
});
return res.json({chat: repositories.getChat(chatId, req.session.user)});
});
router.delete("/chats/:id", (req, res) => {
const result = repositories.deleteChat(Number(req.params.id), req.session.user);
if (result.changes === 0) return res.status(404).json({error: "Chat not found"});
return res.json({message: "Chat deleted"});
});
router.get("/chats/:id/messages", (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
return res.json({
messages: repositories.listChatMessages(chatId).map(chatService.formatChatMessage),
chat
});
});
router.put("/chats/:id/messages/:messageId", validateBody(messageValidator), (req, res) => {
const chatId = Number(req.params.id);
const messageId = Number(req.params.messageId);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
const existingMessage = chatService.formatChatMessage(repositories.getChatMessage(chatId, messageId));
if (!existingMessage) return res.status(404).json({error: "Message not found"});
if (existingMessage.role === "bot") {
const retryVariants = [...existingMessage.retryVariants];
const activeIndex = Number.isInteger(Number(req.validatedBody.retryActiveIndex))
? Number(req.validatedBody.retryActiveIndex)
: existingMessage.retryActiveIndex;
retryVariants[activeIndex] = req.validatedBody.content;
chatService.persistChatMessageRetryState({
chatId,
messageId,
content: req.validatedBody.content,
retryVariants,
retryActiveIndex: activeIndex,
retryRetriesUsed: existingMessage.retryRetriesUsed,
retryPromptMessageId: existingMessage.retryPromptMessageId
});
} else {
repositories.updateChatMessage(chatId, messageId, req.validatedBody.content);
}
repositories.touchChat(chatId, req.session.user);
return res.json({message: chatService.formatChatMessage(repositories.getChatMessage(chatId, messageId))});
});
router.put("/chats/:id/messages/by-index/:index", validateBody(messageValidator), (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
const target = chatService.getChatMessageByIndex(chatId, req.params.index);
if (!target) return res.status(404).json({error: "Message not found"});
const existingMessage = chatService.formatChatMessage(repositories.getChatMessage(chatId, target.id));
if (!existingMessage) return res.status(404).json({error: "Message not found"});
if (existingMessage.role === "bot") {
const retryVariants = [...existingMessage.retryVariants];
const activeIndex = Number.isInteger(Number(req.validatedBody.retryActiveIndex))
? Number(req.validatedBody.retryActiveIndex)
: existingMessage.retryActiveIndex;
retryVariants[activeIndex] = req.validatedBody.content;
chatService.persistChatMessageRetryState({
chatId,
messageId: target.id,
content: req.validatedBody.content,
retryVariants,
retryActiveIndex: activeIndex,
retryRetriesUsed: existingMessage.retryRetriesUsed,
retryPromptMessageId: existingMessage.retryPromptMessageId
});
} else {
repositories.updateChatMessage(chatId, target.id, req.validatedBody.content);
}
repositories.touchChat(chatId, req.session.user);
return res.json({message: chatService.formatChatMessage(repositories.getChatMessage(chatId, target.id))});
});
router.delete("/chats/:id/messages/:messageId", (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
const result = repositories.deleteChatMessage(chatId, Number(req.params.messageId));
if (result.changes === 0) return res.status(404).json({error: "Message not found"});
repositories.touchChat(chatId, req.session.user);
return res.json({message: "Message deleted"});
});
router.delete("/chats/:id/messages/by-index/:index", (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
const target = chatService.getChatMessageByIndex(chatId, req.params.index);
if (!target) return res.status(404).json({error: "Message not found"});
repositories.deleteChatMessage(chatId, target.id);
repositories.touchChat(chatId, req.session.user);
return res.json({message: "Message deleted"});
});
router.post("/chats/:id/clear", (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
repositories.clearChatMessages(chatId);
repositories.touchChat(chatId, req.session.user);
return res.json({message: "Chat cleared"});
});
router.post("/chat", ...chatRateLimiters, validateBody(sendMessageValidator), async (req, res) => {
try {
const result = await chatService.sendChatMessage({
user: req.session.user,
chatId: req.validatedBody.chatId,
message: req.validatedBody.message,
model: req.validatedBody.model
});
if (result.error) return res.status(result.status).json({error: result.error});
return res.json({reply: result.reply});
} catch (error) {
const mapped = modelService.mapError(error);
return res.status(mapped.status).json(mapped.body);
}
});
router.post("/chat/stream", ...chatRateLimiters, validateBody(sendMessageValidator), async (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive"
});
const sendEvent = (event, data) => {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
try {
const result = await chatService.streamChatMessage({
user: req.session.user,
chatId: req.validatedBody.chatId,
message: req.validatedBody.message,
model: req.validatedBody.model,
onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply})
});
if (result.error) {
sendEvent("error", {error: result.error});
} else {
sendEvent("done", {reply: result.reply});
}
} catch (error) {
const mapped = modelService.mapError(error);
sendEvent("error", mapped.body);
} finally {
res.end();
}
});
router.post("/chats/:id/messages/:messageId/retry", ...chatRateLimiters, async (req, res) => {
const chatId = Number(req.params.id);
const messageId = Number(req.params.messageId);
const targetMessage = repositories.getChatMessage(chatId, messageId);
if (!targetMessage) return res.status(404).json({error: "Message not found"});
const latestMessage = repositories.getLatestChatMessage(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 result = await chatService.retryMessage({
user: req.session.user,
chatId,
selectedModel: String(req.body?.model || "mistral:latest"),
targetMessage,
retryStyle: String(req.body?.style || "").trim() || null
});
if (result.error) return res.status(result.status).json({error: result.error});
return res.json(result);
} catch (error) {
const mapped = modelService.mapError(error);
return res.status(mapped.status).json(mapped.body);
}
});
router.post("/chats/:id/messages/by-index/:index/retry", ...chatRateLimiters, async (req, res) => {
const chatId = Number(req.params.id);
const targetMessage = chatService.getChatMessageByIndex(chatId, req.params.index);
if (!targetMessage) return res.status(404).json({error: "Message not found"});
const latestMessage = repositories.getLatestChatMessage(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 result = await chatService.retryMessage({
user: req.session.user,
chatId,
selectedModel: String(req.body?.model || "mistral:latest"),
targetMessage,
retryStyle: String(req.body?.style || "").trim() || null
});
if (result.error) return res.status(result.status).json({error: result.error});
return res.json(result);
} catch (error) {
const mapped = modelService.mapError(error);
return res.status(mapped.status).json(mapped.body);
}
});
router.get("/exports/chats/:id", (req, res) => {
const chatId = Number(req.params.id);
const format = String(req.query.format || "txt").toLowerCase();
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
const messages = repositories.listChatMessages(chatId).map(chatService.formatChatMessage);
if (format === "json") {
return res.json({chat, messages});
}
if (format === "md") {
const body = [`# ${chat.title}`, "", ...messages.flatMap((message) => [`## ${message.role === "user" ? "You" : "Assistant"}`, "", message.content, ""])].join("\n");
res.type("text/markdown");
return res.send(body);
}
const body = [`Chat: ${chat.title}`, "", ...messages.flatMap((message) => [`${message.role === "user" ? "You" : "Assistant"}:`, message.content, ""])].join("\n");
res.type("text/plain");
return res.send(body);
});
router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)}));
router.post("/imports/workspace", (req, res) => res.json({imported: repositories.importWorkspace(req.session.user, req.body?.workspace || {})}));
router.get("/chat/history", (req, res) => res.json({history: repositories.listChats(req.session.user)}));
return router;
}
+11
View File
@@ -0,0 +1,11 @@
import express from "express";
export function createPagesRouter(config) {
const router = express.Router();
router.get("/market", (req, res) => res.sendFile(`${config.publicDir}/market.html`));
router.get("/settings", (req, res) => res.sendFile(`${config.publicDir}/settings.html`));
router.get("/", (req, res) => res.sendFile(`${config.publicDir}/index.html`));
return router;
}
+377
View File
@@ -0,0 +1,377 @@
import express from "express";
import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js";
import {normalizeOptionalText, validatePersonaPayload} from "../services/personaService.js";
const personaBodyValidator = (body) => {
const personaType = String(body?.personaType || "assistant").trim();
if (!["assistant", "user"].includes(personaType)) {
return {error: "Invalid persona type"};
}
const validated = validatePersonaPayload(body, personaType);
if (validated.error) return validated;
return {value: {personaType, payload: validated.value}};
};
const roleplayStartValidator = (body) => {
const assistantPersonaId = Number(body?.assistantPersonaId);
if (!assistantPersonaId) return {error: "assistantPersonaId is required"};
return {
value: {
assistantPersonaId,
userPersonaId: body?.userPersonaId ? Number(body.userPersonaId) : null,
scenarioPrompt: normalizeOptionalText(body?.scenarioPrompt),
model: String(body?.model || "mistral:latest")
}
};
};
const templateValidator = (body) => {
const name = String(body?.name || "").trim();
const promptText = String(body?.promptText || "").trim();
if (!name || !promptText) return {error: "Template name and prompt text are required"};
return {
value: {
personaId: body?.personaId ? Number(body.personaId) : null,
name,
description: normalizeOptionalText(body?.description),
category: normalizeOptionalText(body?.category),
promptText,
starterText: normalizeOptionalText(body?.starterText),
isShared: Boolean(body?.isShared)
}
};
};
export function createPersonasRouter({repositories, chatService, modelService, config}) {
const router = express.Router();
router.use(requireLogin);
const isAdmin = (req) => req.session.user === "admin";
const parseMarketPersonaType = (value) => ["assistant", "user"].includes(value) ? value : null;
router.get("/personas", (req, res) => {
const user = req.session.user;
const active = repositories.getActivePersonaIds(user);
return res.json({
assistantPersonas: repositories.listPersonasByType(user, "assistant"),
userPersonas: repositories.listPersonasByType(user, "user"),
activeUserPersonaId: active.active_user_persona_id ?? null,
publishedPersonaIds: repositories.listPublishedPersonaIds(user)
});
});
router.post("/personas", validateBody(personaBodyValidator), (req, res) => {
return res.json({
persona: repositories.createPersona(req.session.user, req.validatedBody.payload, req.validatedBody.personaType)
});
});
router.put("/personas/:id", validateBody(personaBodyValidator), (req, res) => {
const personaId = Number(req.params.id);
const result = repositories.updatePersona(personaId, req.session.user, req.validatedBody.payload);
if (result.changes === 0) return res.status(404).json({error: "Persona not found"});
return res.json({persona: repositories.getPersona(personaId, req.session.user)});
});
router.post("/personas/:id/clone", (req, res) => {
const persona = repositories.clonePersona(Number(req.params.id), req.session.user);
if (!persona) return res.status(404).json({error: "Persona not found"});
return res.json({persona});
});
router.get("/personas/:id/versions", (req, res) => {
const personaId = Number(req.params.id);
const persona = repositories.getPersona(personaId, req.session.user);
if (!persona) return res.status(404).json({error: "Persona not found"});
return res.json({versions: repositories.listPersonaVersions(personaId, req.session.user)});
});
router.post("/personas/:id/versions/:versionId/restore", (req, res) => {
const persona = repositories.restorePersonaVersion(Number(req.params.id), Number(req.params.versionId), req.session.user);
if (!persona) return res.status(404).json({error: "Version not found"});
return res.json({persona});
});
router.delete("/personas/:id", (req, res) => {
const personaId = Number(req.params.id);
const active = repositories.getActivePersonaIds(req.session.user);
const result = repositories.deletePersona(personaId, req.session.user);
if (result.changes === 0) return res.status(404).json({error: "Persona not found"});
if (active.active_user_persona_id === personaId) {
repositories.setActiveUserPersona(req.session.user, null);
}
repositories.clearPersonaReferences(req.session.user, personaId);
return res.json({message: "Persona deleted"});
});
router.post("/personas/:id/chat", (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 = repositories.getPersona(personaId, user);
if (!persona || persona.persona_type !== "assistant") return res.status(404).json({error: "Persona not found"});
if (userPersonaId) {
const userPersona = repositories.getPersona(userPersonaId, user);
if (!userPersona || userPersona.persona_type !== "user") return res.status(404).json({error: "User persona not found"});
}
try {
return res.json({chat: chatService.getOrCreatePersonaChat(user, persona, userPersonaId, null, null)});
} catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
}
throw error;
}
});
router.post("/personas/:id/equip-user", (req, res) => {
const persona = repositories.getPersona(Number(req.params.id), req.session.user);
if (!persona || persona.persona_type !== "user") return res.status(404).json({error: "Persona not found"});
repositories.setActiveUserPersona(req.session.user, persona.id);
return res.json({activeUserPersonaId: persona.id});
});
router.post("/personas/user/clear", (req, res) => {
repositories.setActiveUserPersona(req.session.user, null);
return res.json({activeUserPersonaId: null});
});
router.get("/personas/market", (req, res) => {
return res.json({
personas: repositories.listMarketPersonas({
admin: isAdmin(req),
username: req.session.user,
personaType: parseMarketPersonaType(String(req.query.personaType || "").trim()),
sort: String(req.query.sort || "best").trim()
})
});
});
router.get("/dashboard/summary", (req, res) => {
return res.json({username: req.session.user, stats: repositories.getDashboardSummary(req.session.user)});
});
router.post("/personas/:id/publish", (req, res) => {
const persona = repositories.getPersona(Number(req.params.id), req.session.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."});
const tags = Array.isArray(req.body?.tags) ? req.body.tags.map((tag) => String(tag || "").trim()).filter(Boolean).slice(0, 8) : [];
return res.json({persona: repositories.upsertMarketPersona(persona.id, req.session.user, persona, tags)});
});
router.post("/personas/:id/unpublish", (req, res) => {
const persona = repositories.getPersona(Number(req.params.id), req.session.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 = repositories.deletePublishedPersona(persona.id, req.session.user);
if (result.changes === 0) return res.status(404).json({error: "Persona is not published."});
return res.json({message: "Persona unpublished"});
});
router.post("/personas/market/:id/collect", (req, res) => {
const user = req.session.user;
const marketPersona = repositories.getMarketPersona(Number(req.params.id));
if (!marketPersona || marketPersona.soft_deleted_at || marketPersona.moderation_status === "rejected") 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."});
if (repositories.getPersonaBySourceMarket(user, marketPersona.id)) return res.status(400).json({error: "You already collected this persona."});
const persona = repositories.collectMarketPersona(user, marketPersona);
repositories.incrementMarketUsageCount(marketPersona.id);
let activeUserPersonaId = null;
if (req.body?.equip && marketPersona.persona_type === "user") {
repositories.setActiveUserPersona(user, persona.id);
activeUserPersonaId = persona.id;
}
return res.json({persona, activeUserPersonaId});
});
router.post("/personas/market/:id/favorite", (req, res) => {
const marketId = Number(req.params.id);
const persona = repositories.getMarketPersona(marketId);
if (!persona) return res.status(404).json({error: "Market persona not found"});
const active = repositories.toggleMarketFavorite(marketId, req.session.user);
return res.json({favorite: active, persona: repositories.getMarketPersona(marketId)});
});
router.post("/personas/market/:id/rate", (req, res) => {
const marketId = Number(req.params.id);
const rating = Number(req.body?.rating);
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return res.status(400).json({error: "Rating must be between 1 and 5"});
const persona = repositories.getMarketPersona(marketId);
if (!persona) return res.status(404).json({error: "Market persona not found"});
return res.json({persona: repositories.rateMarketPersona(marketId, req.session.user, rating)});
});
router.post("/personas/market/:id/report", (req, res) => {
const marketId = Number(req.params.id);
const reason = String(req.body?.reason || "").trim();
if (!reason) return res.status(400).json({error: "Reason is required"});
const persona = repositories.getMarketPersona(marketId);
if (!persona) return res.status(404).json({error: "Market persona not found"});
repositories.reportMarketPersona(marketId, req.session.user, reason, normalizeOptionalText(req.body?.details));
return res.json({message: "Report submitted"});
});
router.post("/personas/market/:id/chat", async (req, res) => {
const user = req.session.user;
const marketPersona = repositories.getMarketPersona(Number(req.params.id));
const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null;
if (!marketPersona || marketPersona.persona_type !== "assistant") {
return res.status(404).json({error: "Market AI Character not found"});
}
let userPersona = null;
if (userPersonaId) {
userPersona = repositories.getPersona(userPersonaId, user);
if (!userPersona || userPersona.persona_type !== "user") {
return res.status(404).json({error: "User persona not found"});
}
}
const existingPersona = repositories.getPersonaBySourceMarket(user, marketPersona.id);
const persona = existingPersona
? repositories.getPersona(existingPersona.id, user)
: repositories.collectMarketPersona(user, marketPersona);
if (!existingPersona) {
repositories.incrementMarketUsageCount(marketPersona.id);
}
const scenarioPrompt = normalizeOptionalText(req.body?.scenarioPrompt);
const sceneSummary = chatService.buildRoleplaySceneSummary(persona, userPersona, scenarioPrompt);
let chat;
try {
chat = chatService.getOrCreatePersonaChat(user, persona, userPersonaId, scenarioPrompt, sceneSummary);
} catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
}
throw error;
}
if (repositories.listChatMessages(chat.id).length > 0) {
return res.json({persona, chat, existing: true, generatedInitialMessage: false});
}
repositories.updateChatScene(chat.id, user, scenarioPrompt, sceneSummary);
chat = repositories.getChat(chat.id, user);
try {
const opener = await chatService.generateRoleplayOpener({
selectedModel: "mistral:latest",
assistantPersona: persona,
userPersona,
scenarioPrompt,
sceneSummary
});
if (!opener || chatService.containsUserVoiceInRoleplayOpener(opener, userPersona)) {
return res.json({persona, chat, existing: false, generatedInitialMessage: false});
}
chatService.insertChatMessage(chat.id, "bot", opener, {
retryVariants: [opener],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
});
repositories.touchChat(chat.id, user);
return res.json({persona, chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener});
} catch {
return res.json({persona, chat, existing: false, generatedInitialMessage: false, degraded: true});
}
});
router.post("/roleplays/start", validateBody(roleplayStartValidator), async (req, res) => {
const user = req.session.user;
const {assistantPersonaId, userPersonaId, scenarioPrompt, model} = req.validatedBody;
const assistantPersona = repositories.getPersona(assistantPersonaId, user);
if (!assistantPersona || assistantPersona.persona_type !== "assistant") return res.status(404).json({error: "Character not found"});
let userPersona = null;
if (userPersonaId) {
userPersona = repositories.getPersona(userPersonaId, user);
if (!userPersona || userPersona.persona_type !== "user") return res.status(404).json({error: "User persona not found"});
}
const sceneSummary = chatService.buildRoleplaySceneSummary(assistantPersona, userPersona, scenarioPrompt);
let chat;
try {
chat = chatService.getOrCreatePersonaChat(user, assistantPersona, userPersonaId, scenarioPrompt, sceneSummary);
} catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
}
throw error;
}
if (repositories.listChatMessages(chat.id).length > 0) {
return res.json({chat, existing: true, generatedInitialMessage: false});
}
repositories.updateChatScene(chat.id, user, scenarioPrompt, sceneSummary);
chat = repositories.getChat(chat.id, user);
try {
const opener = await chatService.generateRoleplayOpener({
selectedModel: model,
assistantPersona,
userPersona,
scenarioPrompt,
sceneSummary
});
if (!opener) return res.status(500).json({error: "Failed to generate the character opener"});
chatService.insertChatMessage(chat.id, "bot", opener, {
retryVariants: [opener],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
});
repositories.touchChat(chat.id, user);
return res.json({chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener});
} catch (error) {
const mapped = modelService.mapError(error);
return res.status(mapped.status).json(mapped.body);
}
});
router.get("/templates", (req, res) => {
const personaId = req.query.personaId ? Number(req.query.personaId) : null;
return res.json({templates: repositories.listPromptTemplatesForPersona(req.session.user, personaId)});
});
router.post("/templates", validateBody(templateValidator), (req, res) => {
return res.json({template: repositories.createPromptTemplate(req.session.user, req.validatedBody)});
});
router.put("/templates/:id", validateBody(templateValidator), (req, res) => {
const result = repositories.updatePromptTemplate(Number(req.params.id), req.session.user, req.validatedBody);
if (result.changes === 0) return res.status(404).json({error: "Template not found"});
return res.json({template: repositories.getPromptTemplate(Number(req.params.id))});
});
router.delete("/templates/:id", (req, res) => {
const result = repositories.deletePromptTemplate(Number(req.params.id), req.session.user);
if (result.changes === 0) return res.status(404).json({error: "Template not found"});
return res.json({message: "Template deleted"});
});
router.get("/admin/reports", (req, res) => {
if (!isAdmin(req)) return res.status(403).json({error: "Admin only"});
return res.json({reports: repositories.listMarketReports(), personas: repositories.listMarketPersonas({admin: true, username: req.session.user})});
});
router.post("/admin/reports/:id", (req, res) => {
if (!isAdmin(req)) return res.status(403).json({error: "Admin only"});
const status = String(req.body?.status || "").trim() || "reviewed";
repositories.updateMarketReportStatus(Number(req.params.id), status);
return res.json({message: "Report updated"});
});
router.post("/admin/personas/market/:id/moderate", (req, res) => {
if (!isAdmin(req)) return res.status(403).json({error: "Admin only"});
const status = String(req.body?.status || "").trim() || "approved";
repositories.updateMarketModeration(Number(req.params.id), {
status,
notes: normalizeOptionalText(req.body?.notes),
softDelete: Boolean(req.body?.softDelete)
});
return res.json({persona: repositories.getMarketPersona(Number(req.params.id))});
});
return router;
}
+66
View File
@@ -0,0 +1,66 @@
import express from "express";
import bcrypt from "bcrypt";
import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js";
const usernameValidator = (body) => {
const username = String(body?.username || "").trim();
const password = String(body?.password || "");
if (!username) return {error: "New username is required"};
if (username.length < 3) return {error: "Username must be at least 3 characters"};
if (!password) return {error: "Current password is required"};
return {value: {username, password}};
};
const passwordValidator = (body) => {
const currentPassword = String(body?.currentPassword || "");
const newPassword = String(body?.newPassword || "");
if (!currentPassword || !newPassword) return {error: "Current and new password are required"};
if (newPassword.length < 6) return {error: "New password must be at least 6 characters"};
return {value: {currentPassword, newPassword}};
};
export function createSettingsRouter({repositories}) {
const router = express.Router();
router.use(requireLogin);
router.get("/settings/profile", (req, res) => {
const user = repositories.getUser(req.session.user);
if (!user) return res.status(404).json({error: "User not found"});
return res.json({username: user.username});
});
router.put("/settings/username", validateBody(usernameValidator), async (req, res) => {
const currentUsername = req.session.user;
const {username: nextUsername, password} = req.validatedBody;
if (nextUsername === currentUsername) {
return res.status(400).json({error: "That is already your username"});
}
const currentUser = repositories.getUser(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 (repositories.getUser(nextUsername)) {
return res.status(400).json({error: "Username already exists"});
}
repositories.renameUser(currentUsername, nextUsername);
req.session.user = nextUsername;
return res.json({username: nextUsername});
});
router.put("/settings/password", validateBody(passwordValidator), async (req, res) => {
const username = req.session.user;
const {currentPassword, newPassword} = req.validatedBody;
const user = repositories.getUser(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(newPassword, 10);
repositories.updatePassword(hashed, username);
return res.json({message: "Password updated"});
});
return router;
}
+12
View File
@@ -0,0 +1,12 @@
import express from "express";
export function createSystemRouter({modelService}) {
const router = express.Router();
router.get("/health", async (req, res) => {
const model = await modelService.checkHealth();
return res.status(model.ok ? 200 : 503).json({ok: model.ok, model});
});
return router;
}
+319
View File
@@ -0,0 +1,319 @@
import {
buildPersonaPrompt,
buildRoleplayDirectionPrompt,
buildRoleplayOpenerPrompt,
buildRoleplayReplyGuardPrompt,
buildRoleplaySceneSummary,
buildUserPersonaPrompt,
containsUserVoiceInRoleplayOpener
} from "./personaService.js";
export function createChatService(repositories, modelService, config) {
const retryStylePrompts = {
shorter: "Rewrite the assistant reply to be shorter and tighter while preserving its meaning.",
direct: "Rewrite the assistant reply to be more direct and less padded.",
emotional: "Rewrite the assistant reply to be more emotionally expressive without changing the core meaning.",
"stay-in-character": "Rewrite the assistant reply to stay more strongly in character and voice.",
dialogue: "Rewrite the assistant reply as dialogue only. Remove narration unless required for clarity."
};
const clampRetryActiveIndex = (index, variants) => {
const numeric = Number(index);
if (!Number.isInteger(numeric) || numeric < 0) return 0;
return Math.min(numeric, Math.max(0, variants.length - 1));
};
const parseRetryVariants = (rawValue, content) => {
if (!rawValue) return [content || ""];
try {
const parsed = JSON.parse(rawValue);
if (Array.isArray(parsed)) {
const normalized = parsed.map((item) => String(item || "").trim()).filter(Boolean);
if (normalized.length) return normalized;
}
} catch {
return [content || ""];
}
return [content || ""];
};
const formatChatMessage = (row) => {
if (!row) return null;
const retryVariants = parseRetryVariants(row.retry_variants, row.content);
const retryActiveIndex = clampRetryActiveIndex(row.retry_active_index, retryVariants);
return {
id: row.id,
role: row.role,
content: row.content,
modelName: row.model_name || null,
retryVariants,
retryActiveIndex,
retryRetriesUsed: Number(row.retry_retries_used || 0),
retryPromptMessageId: row.retry_prompt_message_id ?? null
};
};
const insertChatMessage = (chatId, role, content, retryState = null, modelName = null) => {
const normalizedContent = String(content || "");
const payload = retryState
? {
retryVariants: Array.isArray(retryState.retryVariants) && retryState.retryVariants.length ? retryState.retryVariants : [normalizedContent],
retryActiveIndex: clampRetryActiveIndex(retryState.retryActiveIndex ?? 0, retryState.retryVariants || [normalizedContent]),
retryRetriesUsed: Number(retryState.retryRetriesUsed || 0),
retryPromptMessageId: retryState.retryPromptMessageId ?? null
}
: {
retryVariants: [normalizedContent],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
};
repositories.insertChatMessage(chatId, role, normalizedContent, payload, modelName);
};
const persistChatMessageRetryState = ({chatId, messageId, content, retryVariants, retryActiveIndex, retryRetriesUsed, retryPromptMessageId}) => {
repositories.updateChatMessageRetryState(chatId, messageId, {
content,
retryVariants: retryVariants || [content || ""],
retryActiveIndex: clampRetryActiveIndex(retryActiveIndex, retryVariants || [content || ""]),
retryRetriesUsed: Number(retryRetriesUsed || 0),
retryPromptMessageId: retryPromptMessageId ?? null
});
};
const getChatMessageByIndex = (chatId, index) => {
const safeIndex = Number(index);
if (!Number.isInteger(safeIndex) || safeIndex < 0) return null;
return repositories.getChatMessageByIndex(chatId, safeIndex) || null;
};
const getOrCreatePersonaChat = (user, persona, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null) => {
const existing = repositories.getChatByParticipants(user, persona.id, userPersonaId);
if (existing) return existing;
if (repositories.countChats(user) >= config.chatLimit) {
throw new Error("CHAT_LIMIT_REACHED");
}
return repositories.createChat(user, persona.name, persona.id, userPersonaId, scenarioPrompt, scenarioSummary);
};
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 modelService.generateReply(selectedModel, baseMessages);
if (!containsUserVoiceInRoleplayOpener(opener, userPersona)) {
return opener;
}
return modelService.generateReply(selectedModel, [
...baseMessages,
{role: "assistant", content: opener || ""},
{
role: "user",
content: "Rewrite this opening. Hard rule: only the assistant character may speak or act. Never write or imply user speech, thoughts, feelings, reactions, decisions, actions, or labels like 'User:'/'You:'. Stop before the user's reply."
}
]);
};
const generateValidatedRoleplayReply = async ({selectedModel, messagesPayload, userPersona}) => {
let reply = await modelService.generateReply(selectedModel, messagesPayload);
if (reply && !containsUserVoiceInRoleplayOpener(reply, userPersona)) {
return reply;
}
reply = await modelService.generateReply(selectedModel, [
...messagesPayload,
{role: "assistant", content: reply || ""},
{
role: "user",
content: "Rewrite your last reply. Hard rule: only the assistant character may speak or act. Never write or imply user speech, thoughts, feelings, reactions, decisions, or actions. Stop before the user's reply."
}
]);
if (!reply || containsUserVoiceInRoleplayOpener(reply, userPersona)) {
return null;
}
return reply;
};
const appendRetryStyleInstruction = (messagesPayload, retryStyle) => {
if (!retryStyle || !retryStylePrompts[retryStyle]) return messagesPayload;
return [...messagesPayload, {role: "user", content: retryStylePrompts[retryStyle]}];
};
const buildRetryPayload = async ({chatId, user, session, selectedModel, targetMessage, retryStyle = null}) => {
const activePersona = repositories.getAssistantPersonaForChat(chatId, user);
const activeUserPersona = repositories.getUserPersonaForChat(chatId, user) || repositories.getActiveUserPersona(user);
const promptMessage = repositories.getPreviousUserMessage(chatId, targetMessage.id);
if (!promptMessage) {
if (!activePersona || !session.scenario_summary) {
return {error: "No previous user prompt found for retry"};
}
const opener = await generateRoleplayOpener({
selectedModel,
assistantPersona: activePersona,
userPersona: activeUserPersona,
scenarioPrompt: session.scenario_prompt,
sceneSummary: session.scenario_summary
});
if (!opener || containsUserVoiceInRoleplayOpener(opener, activeUserPersona)) {
return {error: "Failed to regenerate message"};
}
return {fullReply: opener, promptMessageId: null};
}
const conversation = repositories.getRecentChatMessagesUpToId(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 && session.scenario_summary) {
messagesPayload.push({
role: "system",
content: buildRoleplayDirectionPrompt({
assistantPersona: activePersona,
userPersona: activeUserPersona,
scenarioPrompt: session.scenario_prompt,
sceneSummary: session.scenario_summary
})
});
}
messagesPayload.push(...conversation.map((message) => ({
role: message.role === "bot" ? "assistant" : "user",
content: message.content
})));
if (activePersona) messagesPayload.push({role: "system", content: buildRoleplayReplyGuardPrompt()});
const styledPayload = appendRetryStyleInstruction(messagesPayload, retryStyle);
const fullReply = activePersona
? await generateValidatedRoleplayReply({selectedModel, messagesPayload: styledPayload, userPersona: activeUserPersona})
: await modelService.generateReply(selectedModel, styledPayload);
if (!fullReply) return {error: "Failed to regenerate message"};
return {fullReply, promptMessageId: promptMessage.id};
};
return {
formatChatMessage,
getChatMessageByIndex,
getOrCreatePersonaChat,
buildRoleplaySceneSummary,
generateRoleplayOpener,
insertChatMessage,
containsUserVoiceInRoleplayOpener,
persistChatMessageRetryState,
buildMessagesPayload({user, chatId, message}) {
const session = repositories.getChat(chatId, user);
if (!session) return null;
const activePersona = repositories.getAssistantPersonaForChat(chatId, user);
const activeUserPersona = repositories.getUserPersonaForChat(chatId, user) || repositories.getActiveUserPersona(user);
const history = repositories.getRecentChatMessages(chatId).reverse();
const conversation = [...history, {role: "user", content: message}];
const messagesPayload = [];
if (activePersona) messagesPayload.push({role: "system", content: buildPersonaPrompt(activePersona)});
if (activeUserPersona) messagesPayload.push({role: "system", content: buildUserPersonaPrompt(activeUserPersona)});
if (activePersona && session.scenario_summary) {
messagesPayload.push({
role: "system",
content: buildRoleplayDirectionPrompt({
assistantPersona: activePersona,
userPersona: activeUserPersona,
scenarioPrompt: session.scenario_prompt,
sceneSummary: session.scenario_summary
})
});
}
messagesPayload.push(...conversation.map((entry) => ({
role: entry.role === "bot" ? "assistant" : "user",
content: entry.content
})));
if (activePersona) messagesPayload.push({role: "system", content: buildRoleplayReplyGuardPrompt()});
return {session, activePersona, activeUserPersona, messagesPayload};
},
async sendChatMessage({user, chatId, message, model}) {
const session = repositories.getChat(chatId, user);
if (!session) return {error: "Chat not found", status: 404};
const payload = this.buildMessagesPayload({user, chatId, message});
insertChatMessage(chatId, "user", message);
repositories.touchChat(chatId, user);
const activePersona = payload.activePersona;
const activeUserPersona = payload.activeUserPersona;
const messagesPayload = payload.messagesPayload;
const fullReply = activePersona
? await generateValidatedRoleplayReply({selectedModel: model, messagesPayload, userPersona: activeUserPersona})
: await modelService.generateReply(model, messagesPayload);
if (!fullReply) return {error: "Failed to generate a valid character reply", status: 500};
insertChatMessage(chatId, "bot", fullReply, {
retryVariants: [fullReply],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
}, model);
repositories.touchChat(chatId, user);
return {reply: fullReply};
},
async streamChatMessage({user, chatId, message, model, onChunk}) {
const session = repositories.getChat(chatId, user);
if (!session) return {error: "Chat not found", status: 404};
const payload = this.buildMessagesPayload({user, chatId, message});
insertChatMessage(chatId, "user", message);
repositories.touchChat(chatId, user);
const activePersona = payload.activePersona;
const activeUserPersona = payload.activeUserPersona;
const messagesPayload = payload.messagesPayload;
const fullReply = activePersona
? await modelService.streamReply(model, messagesPayload, onChunk)
: await modelService.streamReply(model, messagesPayload, onChunk);
if (!fullReply) return {error: "Failed to generate a valid character reply", status: 500};
insertChatMessage(chatId, "bot", fullReply, {
retryVariants: [fullReply],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
}, model);
repositories.touchChat(chatId, user);
if (activePersona && containsUserVoiceInRoleplayOpener(fullReply, activeUserPersona)) {
return {error: "Failed to generate a valid character reply", status: 500};
}
return {reply: fullReply};
},
async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null}) {
const session = repositories.getChat(chatId, user);
if (!session) return {error: "Chat not found", status: 404};
const retryResult = await buildRetryPayload({chatId, user, session, selectedModel, targetMessage, retryStyle});
if (retryResult.error) return {error: retryResult.error, status: 400};
const existingMessage = formatChatMessage(targetMessage);
const retryVariants = [...existingMessage.retryVariants, retryResult.fullReply];
persistChatMessageRetryState({
chatId,
messageId: targetMessage.id,
content: retryResult.fullReply,
retryVariants,
retryActiveIndex: retryVariants.length - 1,
retryRetriesUsed: existingMessage.retryRetriesUsed + 1,
retryPromptMessageId: retryResult.promptMessageId
});
repositories.touchChat(chatId, user);
return {
message: formatChatMessage(repositories.getChatMessage(chatId, targetMessage.id)),
promptMessageId: retryResult.promptMessageId,
retryStyle
};
}
};
}
+196
View File
@@ -0,0 +1,196 @@
import {exec} from "child_process";
class ModelServiceError extends Error {
constructor(code, message, status = 500) {
super(message);
this.code = code;
this.status = status;
}
}
export function createModelService(config, overrides = {}) {
const modelState = new Map();
let modelsCache = null;
let modelsCacheAt = 0;
let lastHealth = {ok: true, checkedAt: null, error: null};
const fetchImpl = overrides.fetch || global.fetch;
const ensureModelState = (model) => {
if (!modelState.has(model)) {
modelState.set(model, {activeRequests: 0, timer: null});
}
return modelState.get(model);
};
const scheduleModelUnload = (model) => {
const state = ensureModelState(model);
if (state.timer) clearTimeout(state.timer);
state.timer = setTimeout(() => {
if (state.activeRequests > 0) return;
exec(`ollama stop ${model}`, () => {
modelState.delete(model);
});
}, config.modelUnloadAfterMs);
};
const withTimeout = async (timeoutMs, callback) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await callback(controller.signal);
} catch (error) {
if (error?.name === "AbortError") {
throw new ModelServiceError("MODEL_TIMEOUT", "Model backend timed out.", 504);
}
throw error;
} finally {
clearTimeout(timer);
}
};
const readStreamingReply = async (response, onChunk = null) => {
if (!response.body) {
throw new ModelServiceError("MODEL_BAD_RESPONSE", "Model backend returned no body.", 502);
}
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 (onChunk) onChunk(data.message.content, fullReply);
}
}
}
if (buffer.trim()) {
const data = JSON.parse(buffer.trim());
if (data.message?.content) {
fullReply += data.message.content;
if (onChunk) onChunk(data.message.content, fullReply);
}
}
return fullReply.trim();
};
return {
async generateReply(model, messagesPayload) {
const state = ensureModelState(model);
state.activeRequests += 1;
try {
const response = await withTimeout(config.modelRequestTimeoutMs, (signal) => fetchImpl(`${config.modelApiBaseUrl}/chat`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({model, messages: messagesPayload, stream: true}),
signal
}));
if (!response.ok) {
throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502);
}
const fullReply = await readStreamingReply(response);
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null};
return fullReply;
} catch (error) {
lastHealth = {
ok: false,
checkedAt: new Date().toISOString(),
error: error instanceof ModelServiceError ? error.message : "Model backend unavailable."
};
if (error instanceof ModelServiceError) throw error;
throw new ModelServiceError("MODEL_UNAVAILABLE", "Model backend unavailable.", 502);
} finally {
state.activeRequests = Math.max(0, state.activeRequests - 1);
scheduleModelUnload(model);
}
},
async streamReply(model, messagesPayload, onChunk) {
const state = ensureModelState(model);
state.activeRequests += 1;
try {
const response = await withTimeout(config.modelRequestTimeoutMs, (signal) => fetchImpl(`${config.modelApiBaseUrl}/chat`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({model, messages: messagesPayload, stream: true}),
signal
}));
if (!response.ok) {
throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502);
}
const fullReply = await readStreamingReply(response, onChunk);
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null};
return fullReply;
} catch (error) {
lastHealth = {
ok: false,
checkedAt: new Date().toISOString(),
error: error instanceof ModelServiceError ? error.message : "Model backend unavailable."
};
if (error instanceof ModelServiceError) throw error;
throw new ModelServiceError("MODEL_UNAVAILABLE", "Model backend unavailable.", 502);
} finally {
state.activeRequests = Math.max(0, state.activeRequests - 1);
scheduleModelUnload(model);
}
},
async listModels() {
if (modelsCache && Date.now() - modelsCacheAt < config.modelsCacheTtlMs) {
return modelsCache;
}
try {
const response = await withTimeout(config.modelListTimeoutMs, (signal) => fetchImpl(`${config.modelApiBaseUrl}/tags`, {signal}));
if (!response.ok) {
throw new ModelServiceError("MODEL_LIST_FAILED", "Failed to load models.", 502);
}
modelsCache = await response.json();
modelsCacheAt = Date.now();
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null};
return modelsCache;
} catch (error) {
lastHealth = {
ok: false,
checkedAt: new Date().toISOString(),
error: error instanceof ModelServiceError ? error.message : "Failed to load models."
};
if (error instanceof ModelServiceError) throw error;
throw new ModelServiceError("MODEL_UNAVAILABLE", "Failed to load models.", 502);
}
},
async checkHealth() {
try {
await this.listModels();
return {ok: true, checkedAt: new Date().toISOString(), error: null};
} catch (error) {
return {ok: false, checkedAt: new Date().toISOString(), error: error.message};
}
},
getLastHealth() {
return lastHealth;
},
mapError(error) {
if (error instanceof ModelServiceError) {
return {status: error.status, body: {error: error.message, code: error.code}};
}
return {status: 500, body: {error: "Model request failed", code: "MODEL_UNKNOWN"}};
}
};
}
+211
View File
@@ -0,0 +1,211 @@
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 normalizePersonaField = (value) => {
const trimmed = (value || "").trim();
return trimmed || null;
};
export function validatePersonaPayload(payload, personaType = payload?.personaType) {
const sanitized = {
name: (payload?.name || "").trim(),
pronouns: normalizePersonaField(payload?.pronouns),
appearance: normalizePersonaField(payload?.appearance),
background: normalizePersonaField(payload?.background),
details: normalizePersonaField(payload?.details),
exampleDialogues: 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 = Object.values(sanitized).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 {value: sanitized};
}
export function normalizeOptionalText(value) {
const normalized = String(value || "").trim();
return normalized || null;
}
export function 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()}...`;
}
export function 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(" | ");
}
export function 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 imply what the user says next, how they feel, what they decide, or what they physically do.",
"Never complete both sides of the exchange. Only produce the assistant character's side.",
"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");
}
export function 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.",
"Do NOT predict, script, continue, or resolve the user's side of the scene.",
"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");
}
export function 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);
}
export function 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, reactions, or actions for the user.",
"Never output any lines like 'User:' or 'You:'.",
"Never narrate what the user does, says, thinks, feels, wants, decides, notices, or remembers.",
"Never include quoted text that belongs to the user.",
"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");
}
export function 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, quoted lines, thoughts, feelings, reactions, choices, or actions.",
"Do not use labels such as 'User:' or 'You:'.",
"Do not script a back-and-forth exchange. Stop before the user's reply.",
"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");
}
export function buildRoleplayReplyGuardPrompt() {
return [
"ROLEPLAY REPLY RULES:",
"Write only the assistant character's next reply.",
"Never speak as the user.",
"Never write the user's dialogue.",
"Never write the user's thoughts, feelings, reactions, decisions, or actions.",
"Never imply what the user says or does next.",
"Never produce labels like 'User:' or 'You:'.",
"Stop after the assistant character's own reply."
].join("\n");
}
const escapeRegExp = (value) => String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export function 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;
if (new RegExp(`(^|\\n)\\s*(?:${tokens.join("|")})\\s*:`, "i").test(text)) return true;
if (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").test(text)) return true;
if (new RegExp(`["“][^"”\\n]{1,240}["”]\\s*(?:,?\\s*)?(?:${tokens.join("|")})\\s*(?:say|says|said|ask|asks|asked|reply|replies|replied|murmur|murmurs|murmured|whisper|whispers|whispered)\\b`, "i").test(text)) return true;
return new RegExp(`(?:^|\\n|[.!?]\\s+)(?:${tokens.join("|")})\\s+(?:is|was|seems|looks|feels|hesitates|freezes|flinches|nods|smiles|frowns|steps|walks|turns|glances|stares|swallows|breathes)\\b`, "i").test(text);
}