// 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 = 6; // Последние 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 { 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 || ""; } /** * Streaming версия sendMessage - возвращает текст по частям */ export async function sendMessageStream( messages: DeepSeekMessage[], temperature: number = 0.8, onChunk: (chunk: string) => void, signal?: AbortSignal, ): Promise { 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, stream: true, }), signal, }); if (!response.ok) { throw new Error(`DeepSeek API error: ${response.status}`); } const reader = response.body?.getReader(); if (!reader) throw new Error("No response body"); const decoder = new TextDecoder(); let fullContent = ""; while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split("\n").filter((line) => line.trim() !== ""); for (const line of lines) { if (line.startsWith("data: ")) { const data = line.slice(6); if (data === "[DONE]") continue; try { const parsed = JSON.parse(data); const content = parsed.choices?.[0]?.delta?.content || ""; if (content) { fullContent += content; onChunk(content); } } catch { // Ignore parse errors } } } } return fullContent; } /** * Строит базовый системный промпт (правила стиля) - КЭШИРУЕТСЯ */ 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 { // 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); } /** * Streaming версия generateStoryResponse */ export async function generateStoryResponseStream( story: Story, chatHistory: ChatMessage[], userMessage: string, onChunk: (chunk: string) => void, player?: PlayerCharacter, session?: GameSession, signal?: AbortSignal, ): Promise { const styleRules = buildStyleRules(story, player); const worldContext = buildWorldContext(story); const dynamicContext = session ? buildDynamicContext(session) : ""; 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 }, ]; return sendMessageStream(messages, story.temperature || 1.3, onChunk, signal); } /** * Генерирует сводку истории (вызывать периодически) */ export async function generateStorySummary( story: Story, messages: ChatMessage[], previousSummary?: string, ): Promise { // Берём сообщения для суммаризации (исключая последние, они свежие) 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 { // Простая эвристика: ищем важные действия 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 { const messages: DeepSeekMessage[] = [ { role: "system", content: "Ты - писатель исекай историй. Создавай краткие, захватывающие описания для историй.", }, { role: "user", content: `Создай краткое описание (2-3 предложения) для исекай истории: Название: ${title} Жанр: ${genre.join(", ")} Сеттинг: ${setting.join(", ")} Мир: ${worldDescription} Описание должно быть интригующим и заставлять хотеть начать приключение.`, }, ]; return sendMessage(messages, 0.7); }