first commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user