implement an entire base refacotr and alot of styling overhauls and remakes.
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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"}};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user