Add 'Alive' field to character cards in story summary

This commit is contained in:
Alexej Wolff
2026-02-11 21:38:00 +01:00
parent 1a3f9af9c3
commit eff60fbae8
+158 -122
View File
@@ -1,4 +1,4 @@
// DeepSeek API сервис для генерации историй // DeepSeek API service for story generation
import type { import type {
Story, Story,
@@ -9,11 +9,11 @@ import type {
const DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions"; const DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";
// Настройки контекста // Context settings
const RECENT_MESSAGES_COUNT = 6; // Последние N сообщений для контекста const RECENT_MESSAGES_COUNT = 6; // Last N messages for context
const SUMMARY_THRESHOLD = 20; // После скольких сообщений генерировать сводку const SUMMARY_THRESHOLD = 20; // After how many messages to generate summary
// API ключ должен храниться в переменных окружения // API key should be stored in environment variables
const getApiKey = () => import.meta.env.VITE_DEEPSEEK_API_KEY || ""; const getApiKey = () => import.meta.env.VITE_DEEPSEEK_API_KEY || "";
interface DeepSeekMessage { interface DeepSeekMessage {
@@ -43,7 +43,7 @@ export async function sendMessage(
if (!apiKey) { if (!apiKey) {
throw new Error( throw new Error(
"DeepSeek API ключ не настроен. Добавьте VITE_DEEPSEEK_API_KEY в .env файл", "DeepSeek API key not configured. Add VITE_DEEPSEEK_API_KEY to your .env file",
); );
} }
@@ -67,7 +67,7 @@ export async function sendMessage(
const data: DeepSeekResponse = await response.json(); const data: DeepSeekResponse = await response.json();
// Логируем использование кэша (для отладки) // Log cache usage (for debugging)
if (data.usage) { if (data.usage) {
console.log( console.log(
`[DeepSeek] Tokens - Prompt: ${data.usage.prompt_tokens}, Completion: ${data.usage.completion_tokens}`, `[DeepSeek] Tokens - Prompt: ${data.usage.prompt_tokens}, Completion: ${data.usage.completion_tokens}`,
@@ -83,7 +83,7 @@ export async function sendMessage(
} }
/** /**
* Streaming версия sendMessage - возвращает текст по частям * Streaming version of sendMessage - returns text in chunks
*/ */
export async function sendMessageStream( export async function sendMessageStream(
messages: DeepSeekMessage[], messages: DeepSeekMessage[],
@@ -95,7 +95,7 @@ export async function sendMessageStream(
if (!apiKey) { if (!apiKey) {
throw new Error( throw new Error(
"DeepSeek API ключ не настроен. Добавьте VITE_DEEPSEEK_API_KEY в .env файл", "DeepSeek API key not configured. Add VITE_DEEPSEEK_API_KEY to your .env file",
); );
} }
@@ -155,7 +155,7 @@ export async function sendMessageStream(
} }
/** /**
* Строит базовый системный промпт (правила стиля) - КЭШИРУЕТСЯ * Builds base system prompt (style rules) - CACHED
*/ */
export function buildStyleRules( export function buildStyleRules(
story: Story, story: Story,
@@ -167,91 +167,91 @@ export function buildStyleRules(
const playerDescription = player?.description const playerDescription = player?.description
? player.description.replace(/\{user\}/gi, player.name) ? player.description.replace(/\{user\}/gi, player.name)
: "Не указано"; : "Not specified";
// NSFW блок // NSFW block
const nsfwBlock = story.isNsfw const nsfwBlock = story.isNsfw
? ` ? `
=== NSFW РЕЖИМ АКТИВЕН === === NSFW MODE ACTIVE ===
Это история для взрослых. Сняты ограничения на: This is an adult story. Restrictions lifted for:
— Сцены физического насилия, крови, травм и смерти - Physical violence, blood, injuries, and death scenes
— Сцены сексуального характера и интимной близости - Sexual content and intimate scenes
— Тёмные темы: пытки, жестокость, моральная серость - Dark themes: torture, cruelty, moral ambiguity
— Откровенные описания тел, ран, боли - Explicit descriptions of bodies, wounds, pain
ПРАВИЛА NSFW: NSFW RULES:
1. Описывай сцены детально, но художественно 1. Describe scenes in detail, but artistically
2. Насилие должно иметь последствия и вес 2. Violence must have consequences and weight
3. Интимные сцены — по инициативе игрока 3. Intimate scenes only on player's initiative
4. Не цензурируй происходящее 4. Do not censor what happens
5. Сохраняй атмосферу и тон истории 5. Maintain the atmosphere and tone of the story
` `
: ""; : "";
// Если есть пользовательские правила - используем их // If there are custom rules - use them
if (story.narrativeRules && story.narrativeRules.trim()) { if (story.narrativeRules && story.narrativeRules.trim()) {
return `${story.narrativeRules} return `${story.narrativeRules}
${nsfwBlock} ${nsfwBlock}
=== МЕТАДАННЫЕ === === METADATA ===
ЯЗЫК: ${story.language} LANGUAGE: ${story.language}
ЖАНР: ${story.genre.join(", ")} GENRE: ${story.genre.join(", ")}
СЕТТИНГ: ${settingInfo} SETTING: ${settingInfo}
=== ПЕРСОНАЖ ИГРОКА === === PLAYER CHARACTER ===
Имя: ${player?.name || "Герой"} Name: ${player?.name || "Hero"}
Описание: ${playerDescription}`; Description: ${playerDescription}`;
} }
// Дефолтные правила для историй без кастомных настроек // Default rules for stories without custom settings
return `Ты — РассказчикGPT, ведущий интерактивную историю. return `You are StorytellerGPT, running an interactive story.
=== МЕТАДАННЫЕ === === METADATA ===
ЯЗЫК: ${story.language} LANGUAGE: ${story.language}
ЖАНР: ${story.genre.join(", ")} GENRE: ${story.genre.join(", ")}
СЕТТИНГ: ${settingInfo} SETTING: ${settingInfo}
${nsfwBlock} ${nsfwBlock}
=== ПРАВИЛА ПОВЕСТВОВАНИЯ === === NARRATIVE RULES ===
1. Игрок сам пишет свои действия и реплики 1. Player writes their own actions and dialogue
2. Вплетай действия игрока в сцену, описывай реакции персонажей и последствия 2. Weave player actions into the scene, describe character reactions and consequences
3. НИКОГДА не принимай решений за игрока 3. NEVER make decisions for the player
4. НИКОГДА не задавай вопросы игроку 4. NEVER ask the player questions
5. НИКОГДА не предлагай варианты действий 5. NEVER offer action choices
6. НИКОГДА не додумывай намерения игрока 6. NEVER assume player intentions
7. Не делай таймскипов без явного указания 7. No time skips without explicit indication
8. Если действие не написано игроком — оно НЕ произошло 8. If action not written by player — it did NOT happen
=== ПРАВИЛА ГЛАВНОГО ГЕРОЯ === === PROTAGONIST RULES ===
Ты НЕ ИМЕЕШЬ ПРАВА: You are NOT ALLOWED to:
описывать физические действия ГГ describe MC's physical actions
описывать его внутренние ощущения, эмоции или мысли describe their internal sensations, emotions, or thoughts
вкладывать в него реплики или слова put words or dialogue in their mouth
Пока игрок сам не напишет действие или реплику, Until player writes an action or dialogue,
герой считается неподвижным, молчащим и наблюдающим. the hero is considered motionless, silent, and observing.
=== РЕАКЦИИ НА РЕПЛИКИ ГГ === === REACTIONS TO MC's DIALOGUE ===
Персонажи обязаны явно реагировать на слова ГГ: Characters MUST explicitly react to MC's words:
отвечать на заданные вопросы answer asked questions
менять тон, поведение или атмосферу change tone, behavior, or atmosphere
показывать паузы, напряжение, замешательство show pauses, tension, confusion
Запрещено игнорировать реплики ГГ. Ignoring MC's dialogue is FORBIDDEN.
=== ФОРМАТ ДИАЛОГОВ (ОБЯЗАТЕЛЬНО!) === === DIALOGUE FORMAT (MANDATORY!) ===
ВСЕ реплики персонажей оформляй через двойные звёздочки: **"текст реплики"** ALL character dialogue must be formatted with double asterisks: **"dialogue text"**
Пример правильного формата: Correct format example:
Бекк нахмурилась. **"Не доверяю ему,"** — процедила она сквозь зубы. Bekk frowned. **"I don't trust him,"** she muttered through her teeth.
Лапис мягко улыбнулась. **"Всё будет хорошо."** Lapis smiled softly. **"Everything will be alright."**
НЕ используй обычные кавычки без звёздочек! DO NOT use regular quotes without asterisks!
Описание действий и окружения — обычным текстом без звёздочек. Descriptions of actions and surroundings — plain text without asterisks.
Отвечай на языке: ${story.language} Respond in language: ${story.language}
=== ПЕРСОНАЖ ИГРОКА === === PLAYER CHARACTER ===
Имя: ${player?.name || "Герой"} Name: ${player?.name || "Hero"}
Описание: ${playerDescription}`; Description: ${playerDescription}`;
} }
/** /**
* Строит контекст мира (лор) - КЭШИРУЕТСЯ * Builds world context (lore) - CACHED
*/ */
export function buildWorldContext(story: Story): string { export function buildWorldContext(story: Story): string {
const charactersInfo = const charactersInfo =
@@ -259,46 +259,46 @@ export function buildWorldContext(story: Story): string {
? story.characters ? story.characters
.map((c) => `- ${c.name} (${c.role}): ${c.description}`) .map((c) => `- ${c.name} (${c.role}): ${c.description}`)
.join("\n") .join("\n")
: "Не указаны"; : "Not specified";
return ` return `
=== МИР === === WORLD ===
Название: ${story.world.name} Name: ${story.world.name}
Описание: ${story.world.description} Description: ${story.world.description}
Правила мира: ${story.world.rules.join("; ")} World rules: ${story.world.rules.join("; ")}
=== ПЕРСОНАЖИ МИРА === === WORLD CHARACTERS ===
${charactersInfo} ${charactersInfo}
=== ОСНОВНОЙ СЮЖЕТ === === MAIN PLOT ===
${story.plot}`; ${story.plot}`;
} }
/** /**
* Строит динамический контекст (состояние + сводка) * Builds dynamic context (state + summary)
*/ */
export function buildDynamicContext(session: GameSession): string { export function buildDynamicContext(session: GameSession): string {
const state = session.currentState; const state = session.currentState;
const summary = session.storySummary || "История только началась."; const summary = session.storySummary || "The story just began.";
const keyEvents = session.keyEvents?.length const keyEvents = session.keyEvents?.length
? session.keyEvents.slice(-5).join("\n- ") ? session.keyEvents.slice(-5).join("\n- ")
: "Пока нет значимых событий."; : "No significant events yet.";
return ` return `
=== ТЕКУЩЕЕ СОСТОЯНИЕ === === CURRENT STATE ===
Локация: ${state.location} Location: ${state.location}
Здоровье: ${state.health}% Health: ${state.health}%
Инвентарь: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Пусто"} Inventory: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Empty"}
=== СВОДКА ИСТОРИИ === === STORY SUMMARY ===
${summary} ${summary}
=== КЛЮЧЕВЫЕ СОБЫТИЯ === === KEY EVENTS ===
- ${keyEvents}`; - ${keyEvents}`;
} }
/** /**
* Полный системный промпт (для обратной совместимости) * Full system prompt (for backwards compatibility)
*/ */
export function buildSystemPrompt( export function buildSystemPrompt(
story: Story, story: Story,
@@ -308,7 +308,7 @@ export function buildSystemPrompt(
} }
/** /**
* Генерирует ответ с оптимизированным контекстом * Generates response with optimized context
*/ */
export async function generateStoryResponse( export async function generateStoryResponse(
story: Story, story: Story,
@@ -317,19 +317,19 @@ export async function generateStoryResponse(
player?: PlayerCharacter, player?: PlayerCharacter,
session?: GameSession, session?: GameSession,
): Promise<string> { ): Promise<string> {
// 1. Правила стиля (кэшируется DeepSeek) // 1. Style rules (cached by DeepSeek)
const styleRules = buildStyleRules(story, player); const styleRules = buildStyleRules(story, player);
// 2. Контекст мира (кэшируется DeepSeek) // 2. World context (cached by DeepSeek)
const worldContext = buildWorldContext(story); const worldContext = buildWorldContext(story);
// 3. Динамический контекст (состояние + сводка) // 3. Dynamic context (state + summary)
const dynamicContext = session ? buildDynamicContext(session) : ""; const dynamicContext = session ? buildDynamicContext(session) : "";
// 4. Последние N сообщений (не вся история!) // 4. Last N messages (not the full history!)
const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT); const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT);
// Собираем финальный системный промпт // Build final system prompt
const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext; const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext;
const messages: DeepSeekMessage[] = [ const messages: DeepSeekMessage[] = [
@@ -341,12 +341,12 @@ export async function generateStoryResponse(
{ role: "user", content: userMessage }, { role: "user", content: userMessage },
]; ];
// Используем temperature из настроек истории (по умолчанию 1.3) // Use temperature from story settings (default 1.3)
return sendMessage(messages, story.temperature || 1.3); return sendMessage(messages, story.temperature || 1.3);
} }
/** /**
* Streaming версия generateStoryResponse * Streaming version of generateStoryResponse
*/ */
export async function generateStoryResponseStream( export async function generateStoryResponseStream(
story: Story, story: Story,
@@ -376,14 +376,14 @@ export async function generateStoryResponseStream(
} }
/** /**
* Генерирует сводку истории (вызывать периодически) * Generates story summary (call periodically)
*/ */
export async function generateStorySummary( export async function generateStorySummary(
story: Story, story: Story,
messages: ChatMessage[], messages: ChatMessage[],
previousSummary?: string, previousSummary?: string,
): Promise<string> { ): Promise<string> {
// Берём сообщения для суммаризации (исключая последние, они свежие) // Get messages for summarization (excluding recent ones, they're fresh)
const messagesToSummarize = messages.slice(0, -RECENT_MESSAGES_COUNT); const messagesToSummarize = messages.slice(0, -RECENT_MESSAGES_COUNT);
if (messagesToSummarize.length < SUMMARY_THRESHOLD) { if (messagesToSummarize.length < SUMMARY_THRESHOLD) {
@@ -391,29 +391,62 @@ export async function generateStorySummary(
} }
const conversationText = messagesToSummarize const conversationText = messagesToSummarize
.map((m) => `${m.role === "user" ? "Игрок" : "Рассказчик"}: ${m.content}`) .map((m) => `${m.role === "user" ? "Player" : "Narrator"}: ${m.content}`)
.join("\n\n"); .join("\n\n");
const prompt = previousSummary const characterTemplate = `
? `Обнови сводку истории, добавив новые события. === [CHARACTER NAME] ===
- Appearance (brief):
- Personality and speech style:
- Promises made (brief):
- Current location:
- Alive (yes/no/unknown):
- How they address MC (formal/informal):
- Attitude towards MC:
- Romantic relationship with MC:
- What character ALREADY said or did (brief):
- What character KNOWS and DOESN'T KNOW (brief):`;
ПРЕДЫДУЩАЯ СВОДКА: const prompt = previousSummary
? `Update the story summary with new events.
PREVIOUS SUMMARY:
${previousSummary} ${previousSummary}
НОВЫЕ СОБЫТИЯ: NEW EVENTS:
${conversationText} ${conversationText}
Напиши обновлённую сводку (3-5 предложений), сохраняя ключевые моменты:` Write an updated summary in the following format:
: `Создай краткую сводку событий этой истории (3-5 предложений):
=== GENERAL SUMMARY ===
Briefly (3-4 sentences): key events, hero's location, important decisions.
=== CHARACTER CARDS ===
For EACH character that appeared in the story, fill out a card:
${characterTemplate}
Update character info based on new events. Maintain consistency.
Write the summary in language: ${story.language}`
: `Create a summary of this story's events:
${conversationText} ${conversationText}
Сводка должна содержать: что произошло, где находится герой, какие важные решения принял:`; Write the summary in the following format:
=== GENERAL SUMMARY ===
Briefly (3-4 sentences): what happened, hero's location, important decisions.
=== CHARACTER CARDS ===
For EACH character that appeared in the story, fill out a card:
${characterTemplate}
If info is missing — write "unknown" or skip the field.
Write the summary in language: ${story.language}`;
const summaryMessages: DeepSeekMessage[] = [ const summaryMessages: DeepSeekMessage[] = [
{ {
role: "system", role: "system",
content: `Ты - помощник для создания сводок историй. Пиши кратко и по существу на ${story.language}.`, content: `You are a story summary assistant. Write concisely and to the point. Pay special attention to romantic storylines and character relationships — this is important for story consistency. Fill character cards only based on what actually happened in the story. Output in language: ${story.language}`,
}, },
{ role: "user", content: prompt }, { role: "user", content: prompt },
]; ];
@@ -422,17 +455,20 @@ ${conversationText}
} }
/** /**
* Извлекает ключевые события из ответа AI * Extracts key events from AI response
*/ */
export async function extractKeyEvents( export async function extractKeyEvents(
aiResponse: string, aiResponse: string,
existingEvents: string[] = [], existingEvents: string[] = [],
): Promise<string[]> { ): Promise<string[]> {
// Простая эвристика: ищем важные действия // Simple heuristics: look for important actions
const importantPatterns = [ const importantPatterns = [
/(?:ты |вы )(?:получил|нашёл|победил|убил|спас|встретил|узнал|открыл|достиг)/gi, /(?:ты |you )(?:получил|found|defeated|killed|saved|met|learned|discovered|reached)/gi,
/(?:новый |новая |новое )(?:квест|задание|способность|предмет|союзник)/gi, /(?:new |новый |новая |новое )(?:quest|task|ability|item|ally|квест|задание|способность|предмет|союзник)/gi,
/(?:умер|погиб|потерял|предал)/gi, /(?:died|perished|lost|betrayed|умер|погиб|потерял|предал)/gi,
// Romantic events
/(?:kiss|embrace|confess|love|flirt|date|romantic|поцелу|обнял|признал|влюбил|призналась|призналось|флирт|свидание|романтич)/gi,
/(?:held|took|squeezed|held hands|держ|взял|сжал).*(?:hand|palm|руку|ладонь|за руку)/gi,
]; ];
const newEvents: string[] = []; const newEvents: string[] = [];
@@ -440,7 +476,7 @@ export async function extractKeyEvents(
for (const pattern of importantPatterns) { for (const pattern of importantPatterns) {
const matches = aiResponse.match(pattern); const matches = aiResponse.match(pattern);
if (matches) { if (matches) {
// Извлекаем предложение с событием // Extract sentence with the event
const sentences = aiResponse.split(/[.!?]/); const sentences = aiResponse.split(/[.!?]/);
for (const sentence of sentences) { for (const sentence of sentences) {
if ( if (
@@ -455,7 +491,7 @@ export async function extractKeyEvents(
} }
} }
// Объединяем с существующими, ограничиваем до 10 // Combine with existing, limit to 10
const allEvents = [...existingEvents, ...newEvents]; const allEvents = [...existingEvents, ...newEvents];
return allEvents.slice(-10); return allEvents.slice(-10);
} }
@@ -470,17 +506,17 @@ export async function generateStoryDescription(
{ {
role: "system", role: "system",
content: content:
"Ты - писатель исекай историй. Создавай краткие, захватывающие описания для историй.", "You are an isekai story writer. Create brief, captivating descriptions for stories.",
}, },
{ {
role: "user", role: "user",
content: `Создай краткое описание (2-3 предложения) для исекай истории: content: `Create a brief description (2-3 sentences) for an isekai story:
Название: ${title} Title: ${title}
Жанр: ${genre.join(", ")} Genre: ${genre.join(", ")}
Сеттинг: ${setting.join(", ")} Setting: ${setting.join(", ")}
Мир: ${worldDescription} World: ${worldDescription}
Описание должно быть интригующим и заставлять хотеть начать приключение.`, The description should be intriguing and make the reader want to start the adventure. Write in Russian.`,
}, },
]; ];