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 {
Story,
@@ -9,11 +9,11 @@ import type {
const DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";
// Настройки контекста
const RECENT_MESSAGES_COUNT = 6; // Последние N сообщений для контекста
const SUMMARY_THRESHOLD = 20; // После скольких сообщений генерировать сводку
// Context settings
const RECENT_MESSAGES_COUNT = 6; // Last N messages for context
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 || "";
interface DeepSeekMessage {
@@ -43,7 +43,7 @@ export async function sendMessage(
if (!apiKey) {
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();
// Логируем использование кэша (для отладки)
// Log cache usage (for debugging)
if (data.usage) {
console.log(
`[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(
messages: DeepSeekMessage[],
@@ -95,7 +95,7 @@ export async function sendMessageStream(
if (!apiKey) {
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(
story: Story,
@@ -167,91 +167,91 @@ export function buildStyleRules(
const playerDescription = player?.description
? player.description.replace(/\{user\}/gi, player.name)
: "Не указано";
: "Not specified";
// NSFW блок
// NSFW block
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:
1. Описывай сцены детально, но художественно
2. Насилие должно иметь последствия и вес
3. Интимные сцены — по инициативе игрока
4. Не цензурируй происходящее
5. Сохраняй атмосферу и тон истории
NSFW RULES:
1. Describe scenes in detail, but artistically
2. Violence must have consequences and weight
3. Intimate scenes only on player's initiative
4. Do not censor what happens
5. Maintain the atmosphere and tone of the story
`
: "";
// Если есть пользовательские правила - используем их
// If there are custom rules - use them
if (story.narrativeRules && story.narrativeRules.trim()) {
return `${story.narrativeRules}
${nsfwBlock}
=== МЕТАДАННЫЕ ===
ЯЗЫК: ${story.language}
ЖАНР: ${story.genre.join(", ")}
СЕТТИНГ: ${settingInfo}
=== METADATA ===
LANGUAGE: ${story.language}
GENRE: ${story.genre.join(", ")}
SETTING: ${settingInfo}
=== ПЕРСОНАЖ ИГРОКА ===
Имя: ${player?.name || "Герой"}
Описание: ${playerDescription}`;
=== PLAYER CHARACTER ===
Name: ${player?.name || "Hero"}
Description: ${playerDescription}`;
}
// Дефолтные правила для историй без кастомных настроек
return `Ты — РассказчикGPT, ведущий интерактивную историю.
// Default rules for stories without custom settings
return `You are StorytellerGPT, running an interactive story.
=== МЕТАДАННЫЕ ===
ЯЗЫК: ${story.language}
ЖАНР: ${story.genre.join(", ")}
СЕТТИНГ: ${settingInfo}
=== METADATA ===
LANGUAGE: ${story.language}
GENRE: ${story.genre.join(", ")}
SETTING: ${settingInfo}
${nsfwBlock}
=== ПРАВИЛА ПОВЕСТВОВАНИЯ ===
1. Игрок сам пишет свои действия и реплики
2. Вплетай действия игрока в сцену, описывай реакции персонажей и последствия
3. НИКОГДА не принимай решений за игрока
4. НИКОГДА не задавай вопросы игроку
5. НИКОГДА не предлагай варианты действий
6. НИКОГДА не додумывай намерения игрока
7. Не делай таймскипов без явного указания
8. Если действие не написано игроком — оно НЕ произошло
=== NARRATIVE RULES ===
1. Player writes their own actions and dialogue
2. Weave player actions into the scene, describe character reactions and consequences
3. NEVER make decisions for the player
4. NEVER ask the player questions
5. NEVER offer action choices
6. NEVER assume player intentions
7. No time skips without explicit indication
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."**
НЕ используй обычные кавычки без звёздочек!
Описание действий и окружения — обычным текстом без звёздочек.
Отвечай на языке: ${story.language}
DO NOT use regular quotes without asterisks!
Descriptions of actions and surroundings — plain text without asterisks.
Respond in language: ${story.language}
=== ПЕРСОНАЖ ИГРОКА ===
Имя: ${player?.name || "Герой"}
Описание: ${playerDescription}`;
=== PLAYER CHARACTER ===
Name: ${player?.name || "Hero"}
Description: ${playerDescription}`;
}
/**
* Строит контекст мира (лор) - КЭШИРУЕТСЯ
* Builds world context (lore) - CACHED
*/
export function buildWorldContext(story: Story): string {
const charactersInfo =
@@ -259,46 +259,46 @@ export function buildWorldContext(story: Story): string {
? story.characters
.map((c) => `- ${c.name} (${c.role}): ${c.description}`)
.join("\n")
: "Не указаны";
: "Not specified";
return `
=== МИР ===
Название: ${story.world.name}
Описание: ${story.world.description}
Правила мира: ${story.world.rules.join("; ")}
=== WORLD ===
Name: ${story.world.name}
Description: ${story.world.description}
World rules: ${story.world.rules.join("; ")}
=== ПЕРСОНАЖИ МИРА ===
=== WORLD CHARACTERS ===
${charactersInfo}
=== ОСНОВНОЙ СЮЖЕТ ===
=== MAIN PLOT ===
${story.plot}`;
}
/**
* Строит динамический контекст (состояние + сводка)
* Builds dynamic context (state + summary)
*/
export function buildDynamicContext(session: GameSession): string {
const state = session.currentState;
const summary = session.storySummary || "История только началась.";
const summary = session.storySummary || "The story just began.";
const keyEvents = session.keyEvents?.length
? session.keyEvents.slice(-5).join("\n- ")
: "Пока нет значимых событий.";
: "No significant events yet.";
return `
=== ТЕКУЩЕЕ СОСТОЯНИЕ ===
Локация: ${state.location}
Здоровье: ${state.health}%
Инвентарь: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Пусто"}
=== CURRENT STATE ===
Location: ${state.location}
Health: ${state.health}%
Inventory: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Empty"}
=== СВОДКА ИСТОРИИ ===
=== STORY SUMMARY ===
${summary}
=== КЛЮЧЕВЫЕ СОБЫТИЯ ===
=== KEY EVENTS ===
- ${keyEvents}`;
}
/**
* Полный системный промпт (для обратной совместимости)
* Full system prompt (for backwards compatibility)
*/
export function buildSystemPrompt(
story: Story,
@@ -308,7 +308,7 @@ export function buildSystemPrompt(
}
/**
* Генерирует ответ с оптимизированным контекстом
* Generates response with optimized context
*/
export async function generateStoryResponse(
story: Story,
@@ -317,19 +317,19 @@ export async function generateStoryResponse(
player?: PlayerCharacter,
session?: GameSession,
): Promise<string> {
// 1. Правила стиля (кэшируется DeepSeek)
// 1. Style rules (cached by DeepSeek)
const styleRules = buildStyleRules(story, player);
// 2. Контекст мира (кэшируется DeepSeek)
// 2. World context (cached by DeepSeek)
const worldContext = buildWorldContext(story);
// 3. Динамический контекст (состояние + сводка)
// 3. Dynamic context (state + summary)
const dynamicContext = session ? buildDynamicContext(session) : "";
// 4. Последние N сообщений (не вся история!)
// 4. Last N messages (not the full history!)
const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT);
// Собираем финальный системный промпт
// Build final system prompt
const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext;
const messages: DeepSeekMessage[] = [
@@ -341,12 +341,12 @@ export async function generateStoryResponse(
{ role: "user", content: userMessage },
];
// Используем temperature из настроек истории (по умолчанию 1.3)
// Use temperature from story settings (default 1.3)
return sendMessage(messages, story.temperature || 1.3);
}
/**
* Streaming версия generateStoryResponse
* Streaming version of generateStoryResponse
*/
export async function generateStoryResponseStream(
story: Story,
@@ -376,14 +376,14 @@ export async function generateStoryResponseStream(
}
/**
* Генерирует сводку истории (вызывать периодически)
* Generates story summary (call periodically)
*/
export async function generateStorySummary(
story: Story,
messages: ChatMessage[],
previousSummary?: string,
): Promise<string> {
// Берём сообщения для суммаризации (исключая последние, они свежие)
// Get messages for summarization (excluding recent ones, they're fresh)
const messagesToSummarize = messages.slice(0, -RECENT_MESSAGES_COUNT);
if (messagesToSummarize.length < SUMMARY_THRESHOLD) {
@@ -391,29 +391,62 @@ export async function generateStorySummary(
}
const conversationText = messagesToSummarize
.map((m) => `${m.role === "user" ? "Игрок" : "Рассказчик"}: ${m.content}`)
.map((m) => `${m.role === "user" ? "Player" : "Narrator"}: ${m.content}`)
.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}
НОВЫЕ СОБЫТИЯ:
NEW EVENTS:
${conversationText}
Напиши обновлённую сводку (3-5 предложений), сохраняя ключевые моменты:`
: `Создай краткую сводку событий этой истории (3-5 предложений):
Write an updated summary in the following format:
=== 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}
Сводка должна содержать: что произошло, где находится герой, какие важные решения принял:`;
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[] = [
{
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 },
];
@@ -422,17 +455,20 @@ ${conversationText}
}
/**
* Извлекает ключевые события из ответа AI
* Extracts key events from AI response
*/
export async function extractKeyEvents(
aiResponse: string,
existingEvents: string[] = [],
): Promise<string[]> {
// Простая эвристика: ищем важные действия
// Simple heuristics: look for important actions
const importantPatterns = [
/(?:ты |вы )(?:получил|нашёл|победил|убил|спас|встретил|узнал|открыл|достиг)/gi,
/(?:новый |новая |новое )(?:квест|задание|способность|предмет|союзник)/gi,
/(?:умер|погиб|потерял|предал)/gi,
/(?:ты |you )(?:получил|found|defeated|killed|saved|met|learned|discovered|reached)/gi,
/(?:new |новый |новая |новое )(?:quest|task|ability|item|ally|квест|задание|способность|предмет|союзник)/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[] = [];
@@ -440,7 +476,7 @@ export async function extractKeyEvents(
for (const pattern of importantPatterns) {
const matches = aiResponse.match(pattern);
if (matches) {
// Извлекаем предложение с событием
// Extract sentence with the event
const sentences = aiResponse.split(/[.!?]/);
for (const sentence of sentences) {
if (
@@ -455,7 +491,7 @@ export async function extractKeyEvents(
}
}
// Объединяем с существующими, ограничиваем до 10
// Combine with existing, limit to 10
const allEvents = [...existingEvents, ...newEvents];
return allEvents.slice(-10);
}
@@ -470,17 +506,17 @@ export async function generateStoryDescription(
{
role: "system",
content:
"Ты - писатель исекай историй. Создавай краткие, захватывающие описания для историй.",
"You are an isekai story writer. Create brief, captivating descriptions for stories.",
},
{
role: "user",
content: `Создай краткое описание (2-3 предложения) для исекай истории:
Название: ${title}
Жанр: ${genre.join(", ")}
Сеттинг: ${setting.join(", ")}
Мир: ${worldDescription}
content: `Create a brief description (2-3 sentences) for an isekai story:
Title: ${title}
Genre: ${genre.join(", ")}
Setting: ${setting.join(", ")}
World: ${worldDescription}
Описание должно быть интригующим и заставлять хотеть начать приключение.`,
The description should be intriguing and make the reader want to start the adventure. Write in Russian.`,
},
];