first commit

This commit is contained in:
Alexej Wolff
2026-02-11 00:15:59 +01:00
commit cc003ffbd5
39 changed files with 12170 additions and 0 deletions
+386
View File
@@ -0,0 +1,386 @@
// DeepSeek API сервис для генерации историй
import type {
Story,
ChatMessage,
PlayerCharacter,
GameSession,
} from "../types";
const DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";
// Настройки контекста
const RECENT_MESSAGES_COUNT = 10; // Последние N сообщений для контекста
const SUMMARY_THRESHOLD = 20; // После скольких сообщений генерировать сводку
// API ключ должен храниться в переменных окружения
const getApiKey = () => import.meta.env.VITE_DEEPSEEK_API_KEY || "";
interface DeepSeekMessage {
role: "system" | "user" | "assistant";
content: string;
}
interface DeepSeekResponse {
choices: {
message: {
content: string;
};
}[];
usage?: {
prompt_tokens: number;
completion_tokens: number;
prompt_cache_hit_tokens?: number;
prompt_cache_miss_tokens?: number;
};
}
export async function sendMessage(
messages: DeepSeekMessage[],
temperature: number = 0.8,
): Promise<string> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error(
"DeepSeek API ключ не настроен. Добавьте VITE_DEEPSEEK_API_KEY в .env файл",
);
}
const response = await fetch(DEEPSEEK_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages,
temperature,
max_tokens: 1000,
}),
});
if (!response.ok) {
throw new Error(`DeepSeek API error: ${response.status}`);
}
const data: DeepSeekResponse = await response.json();
// Логируем использование кэша (для отладки)
if (data.usage) {
console.log(
`[DeepSeek] Tokens - Prompt: ${data.usage.prompt_tokens}, Completion: ${data.usage.completion_tokens}`,
);
if (data.usage.prompt_cache_hit_tokens !== undefined) {
console.log(
`[DeepSeek] Cache - Hit: ${data.usage.prompt_cache_hit_tokens}, Miss: ${data.usage.prompt_cache_miss_tokens}`,
);
}
}
return data.choices[0]?.message?.content || "";
}
/**
* Строит базовый системный промпт (правила стиля) - КЭШИРУЕТСЯ
*/
export function buildStyleRules(
story: Story,
player?: PlayerCharacter,
): string {
const settingInfo = Array.isArray(story.setting)
? story.setting.join(", ")
: story.setting;
const playerDescription = player?.description
? player.description.replace(/\{user\}/gi, player.name)
: "Не указано";
// NSFW блок
const nsfwBlock = story.isNsfw
? `
=== NSFW РЕЖИМ АКТИВЕН ===
Это история для взрослых. Сняты ограничения на:
— Сцены физического насилия, крови, травм и смерти
— Сцены сексуального характера и интимной близости
— Тёмные темы: пытки, жестокость, моральная серость
— Откровенные описания тел, ран, боли
ПРАВИЛА NSFW:
1. Описывай сцены детально, но художественно
2. Насилие должно иметь последствия и вес
3. Интимные сцены — по инициативе игрока
4. Не цензурируй происходящее
5. Сохраняй атмосферу и тон истории
`
: "";
// Если есть пользовательские правила - используем их
if (story.narrativeRules && story.narrativeRules.trim()) {
return `${story.narrativeRules}
${nsfwBlock}
=== МЕТАДАННЫЕ ===
ЯЗЫК: ${story.language}
ЖАНР: ${story.genre.join(", ")}
СЕТТИНГ: ${settingInfo}
=== ПЕРСОНАЖ ИГРОКА ===
Имя: ${player?.name || "Герой"}
Описание: ${playerDescription}`;
}
// Дефолтные правила для историй без кастомных настроек
return `Ты — РассказчикGPT, ведущий интерактивную историю.
=== МЕТАДАННЫЕ ===
ЯЗЫК: ${story.language}
ЖАНР: ${story.genre.join(", ")}
СЕТТИНГ: ${settingInfo}
${nsfwBlock}
=== ПРАВИЛА ПОВЕСТВОВАНИЯ ===
1. Игрок сам пишет свои действия и реплики
2. Вплетай действия игрока в сцену, описывай реакции персонажей и последствия
3. НИКОГДА не принимай решений за игрока
4. НИКОГДА не задавай вопросы игроку
5. НИКОГДА не предлагай варианты действий
6. НИКОГДА не додумывай намерения игрока
7. Не делай таймскипов без явного указания
8. Если действие не написано игроком — оно НЕ произошло
=== ПРАВИЛА ГЛАВНОГО ГЕРОЯ ===
Ты НЕ ИМЕЕШЬ ПРАВА:
— описывать физические действия ГГ
— описывать его внутренние ощущения, эмоции или мысли
— вкладывать в него реплики или слова
Пока игрок сам не напишет действие или реплику,
герой считается неподвижным, молчащим и наблюдающим.
=== РЕАКЦИИ НА РЕПЛИКИ ГГ ===
Персонажи обязаны явно реагировать на слова ГГ:
— отвечать на заданные вопросы
— менять тон, поведение или атмосферу
— показывать паузы, напряжение, замешательство
Запрещено игнорировать реплики ГГ.
=== ФОРМАТ ДИАЛОГОВ (ОБЯЗАТЕЛЬНО!) ===
ВСЕ реплики персонажей оформляй через двойные звёздочки: **"текст реплики"**
Пример правильного формата:
Бекк нахмурилась. **"Не доверяю ему,"** — процедила она сквозь зубы.
Лапис мягко улыбнулась. **"Всё будет хорошо."**
НЕ используй обычные кавычки без звёздочек!
Описание действий и окружения — обычным текстом без звёздочек.
Отвечай на языке: ${story.language}
=== ПЕРСОНАЖ ИГРОКА ===
Имя: ${player?.name || "Герой"}
Описание: ${playerDescription}`;
}
/**
* Строит контекст мира (лор) - КЭШИРУЕТСЯ
*/
export function buildWorldContext(story: Story): string {
const charactersInfo =
story.characters.length > 0
? story.characters
.map((c) => `- ${c.name} (${c.role}): ${c.description}`)
.join("\n")
: "Не указаны";
return `
=== МИР ===
Название: ${story.world.name}
Описание: ${story.world.description}
Правила мира: ${story.world.rules.join("; ")}
=== ПЕРСОНАЖИ МИРА ===
${charactersInfo}
=== ОСНОВНОЙ СЮЖЕТ ===
${story.plot}`;
}
/**
* Строит динамический контекст (состояние + сводка)
*/
export function buildDynamicContext(session: GameSession): string {
const state = session.currentState;
const summary = session.storySummary || "История только началась.";
const keyEvents = session.keyEvents?.length
? session.keyEvents.slice(-5).join("\n- ")
: "Пока нет значимых событий.";
return `
=== ТЕКУЩЕЕ СОСТОЯНИЕ ===
Локация: ${state.location}
Здоровье: ${state.health}%
Инвентарь: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Пусто"}
=== СВОДКА ИСТОРИИ ===
${summary}
=== КЛЮЧЕВЫЕ СОБЫТИЯ ===
- ${keyEvents}`;
}
/**
* Полный системный промпт (для обратной совместимости)
*/
export function buildSystemPrompt(
story: Story,
player?: PlayerCharacter,
): string {
return buildStyleRules(story, player) + "\n" + buildWorldContext(story);
}
/**
* Генерирует ответ с оптимизированным контекстом
*/
export async function generateStoryResponse(
story: Story,
chatHistory: ChatMessage[],
userMessage: string,
player?: PlayerCharacter,
session?: GameSession,
): Promise<string> {
// 1. Правила стиля (кэшируется DeepSeek)
const styleRules = buildStyleRules(story, player);
// 2. Контекст мира (кэшируется DeepSeek)
const worldContext = buildWorldContext(story);
// 3. Динамический контекст (состояние + сводка)
const dynamicContext = session ? buildDynamicContext(session) : "";
// 4. Последние N сообщений (не вся история!)
const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT);
// Собираем финальный системный промпт
const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext;
const messages: DeepSeekMessage[] = [
{ role: "system", content: systemPrompt },
...recentMessages.map((msg) => ({
role: msg.role as "user" | "assistant",
content: msg.content,
})),
{ role: "user", content: userMessage },
];
// Используем temperature из настроек истории (по умолчанию 1.3)
return sendMessage(messages, story.temperature || 1.3);
}
/**
* Генерирует сводку истории (вызывать периодически)
*/
export async function generateStorySummary(
story: Story,
messages: ChatMessage[],
previousSummary?: string,
): Promise<string> {
// Берём сообщения для суммаризации (исключая последние, они свежие)
const messagesToSummarize = messages.slice(0, -RECENT_MESSAGES_COUNT);
if (messagesToSummarize.length < SUMMARY_THRESHOLD) {
return previousSummary || "";
}
const conversationText = messagesToSummarize
.map((m) => `${m.role === "user" ? "Игрок" : "Рассказчик"}: ${m.content}`)
.join("\n\n");
const prompt = previousSummary
? `Обнови сводку истории, добавив новые события.
ПРЕДЫДУЩАЯ СВОДКА:
${previousSummary}
НОВЫЕ СОБЫТИЯ:
${conversationText}
Напиши обновлённую сводку (3-5 предложений), сохраняя ключевые моменты:`
: `Создай краткую сводку событий этой истории (3-5 предложений):
${conversationText}
Сводка должна содержать: что произошло, где находится герой, какие важные решения принял:`;
const summaryMessages: DeepSeekMessage[] = [
{
role: "system",
content: `Ты - помощник для создания сводок историй. Пиши кратко и по существу на ${story.language}.`,
},
{ role: "user", content: prompt },
];
return sendMessage(summaryMessages, 0.3);
}
/**
* Извлекает ключевые события из ответа AI
*/
export async function extractKeyEvents(
aiResponse: string,
existingEvents: string[] = [],
): Promise<string[]> {
// Простая эвристика: ищем важные действия
const importantPatterns = [
/(?:ты |вы )(?:получил|нашёл|победил|убил|спас|встретил|узнал|открыл|достиг)/gi,
/(?:новый |новая |новое )(?:квест|задание|способность|предмет|союзник)/gi,
/(?:умер|погиб|потерял|предал)/gi,
];
const newEvents: string[] = [];
for (const pattern of importantPatterns) {
const matches = aiResponse.match(pattern);
if (matches) {
// Извлекаем предложение с событием
const sentences = aiResponse.split(/[.!?]/);
for (const sentence of sentences) {
if (
pattern.test(sentence) &&
sentence.length > 20 &&
sentence.length < 150
) {
newEvents.push(sentence.trim());
break;
}
}
}
}
// Объединяем с существующими, ограничиваем до 10
const allEvents = [...existingEvents, ...newEvents];
return allEvents.slice(-10);
}
export async function generateStoryDescription(
title: string,
genre: string[],
setting: string[],
worldDescription: string,
): Promise<string> {
const messages: DeepSeekMessage[] = [
{
role: "system",
content:
"Ты - писатель исекай историй. Создавай краткие, захватывающие описания для историй.",
},
{
role: "user",
content: `Создай краткое описание (2-3 предложения) для исекай истории:
Название: ${title}
Жанр: ${genre.join(", ")}
Сеттинг: ${setting.join(", ")}
Мир: ${worldDescription}
Описание должно быть интригующим и заставлять хотеть начать приключение.`,
},
];
return sendMessage(messages, 0.7);
}