489 lines
16 KiB
TypeScript
489 lines
16 KiB
TypeScript
// 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<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 || "";
|
|
}
|
|
|
|
/**
|
|
* Streaming версия sendMessage - возвращает текст по частям
|
|
*/
|
|
export async function sendMessageStream(
|
|
messages: DeepSeekMessage[],
|
|
temperature: number = 0.8,
|
|
onChunk: (chunk: string) => void,
|
|
signal?: AbortSignal,
|
|
): 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,
|
|
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<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);
|
|
}
|
|
|
|
/**
|
|
* Streaming версия generateStoryResponse
|
|
*/
|
|
export async function generateStoryResponseStream(
|
|
story: Story,
|
|
chatHistory: ChatMessage[],
|
|
userMessage: string,
|
|
onChunk: (chunk: string) => void,
|
|
player?: PlayerCharacter,
|
|
session?: GameSession,
|
|
signal?: AbortSignal,
|
|
): Promise<string> {
|
|
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<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);
|
|
}
|