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