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
+302
View File
@@ -0,0 +1,302 @@
// API сервис для работы с бэкендом
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3001";
// ============ AUTH ============
export interface User {
id: string;
discordId: string;
username: string;
email: string;
avatar: string | null;
}
export function getDiscordLoginUrl(): string {
return `${API_URL}/auth/discord`;
}
export async function getCurrentUser(): Promise<User | null> {
try {
const response = await fetch(`${API_URL}/auth/me`, {
credentials: "include",
});
const data = await response.json();
return data.user;
} catch (error) {
console.error("Failed to get current user:", error);
return null;
}
}
export async function logout(): Promise<boolean> {
try {
const response = await fetch(`${API_URL}/auth/logout`, {
method: "POST",
credentials: "include",
});
return response.ok;
} catch (error) {
console.error("Failed to logout:", error);
return false;
}
}
export function getDiscordAvatarUrl(user: User): string {
if (user.avatar) {
return `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png`;
}
// Default Discord avatar
const defaultAvatarIndex = parseInt(user.discordId) % 5;
return `https://cdn.discordapp.com/embed/avatars/${defaultAvatarIndex}.png`;
}
// ============ STORIES ============
import type { Story } from "../types";
export async function getStories(): Promise<Story[]> {
try {
const response = await fetch(`${API_URL}/api/stories`, {
credentials: "include",
});
if (response.status === 401) {
return [];
}
if (!response.ok) {
throw new Error("Failed to fetch stories");
}
return await response.json();
} catch (error) {
console.error("Failed to get stories:", error);
return [];
}
}
export async function getStory(id: string): Promise<Story | null> {
try {
const response = await fetch(`${API_URL}/api/stories/${id}`, {
credentials: "include",
});
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error("Failed to get story:", error);
return null;
}
}
export async function createStory(
story: Omit<Story, "id" | "createdAt" | "updatedAt">,
): Promise<Story | null> {
try {
const response = await fetch(`${API_URL}/api/stories`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(story),
});
if (!response.ok) {
throw new Error("Failed to create story");
}
const data = await response.json();
return { ...data, id: data._id };
} catch (error) {
console.error("Failed to create story:", error);
return null;
}
}
export async function updateStory(
id: string,
story: Partial<Story>,
): Promise<boolean> {
try {
const response = await fetch(`${API_URL}/api/stories/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(story),
});
return response.ok;
} catch (error) {
console.error("Failed to update story:", error);
return false;
}
}
export async function deleteStory(id: string): Promise<boolean> {
try {
const response = await fetch(`${API_URL}/api/stories/${id}`, {
method: "DELETE",
credentials: "include",
});
return response.ok;
} catch (error) {
console.error("Failed to delete story:", error);
return false;
}
}
// ============ GAME SESSIONS ============
import type { GameSession } from "../types";
export async function getSession(storyId: string): Promise<GameSession | null> {
try {
const response = await fetch(`${API_URL}/api/sessions/${storyId}`, {
credentials: "include",
});
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error("Failed to get session:", error);
return null;
}
}
export async function saveSession(
storyId: string,
session: Omit<GameSession, "storyId">,
): Promise<boolean> {
try {
const response = await fetch(`${API_URL}/api/sessions/${storyId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(session),
});
return response.ok;
} catch (error) {
console.error("Failed to save session:", error);
return false;
}
}
// ============ PLAYER CHARACTERS ============
import type { PlayerCharacter } from "../types";
export async function getPlayerCharacters(): Promise<PlayerCharacter[]> {
try {
const response = await fetch(`${API_URL}/api/characters`, {
credentials: "include",
});
if (response.status === 401) {
return [];
}
if (!response.ok) {
throw new Error("Failed to fetch characters");
}
const data = await response.json();
return data.map((c: any) => ({ ...c, id: c._id || c.id }));
} catch (error) {
console.error("Failed to get characters:", error);
return [];
}
}
export async function getPlayerCharacter(
id: string,
): Promise<PlayerCharacter | null> {
try {
const response = await fetch(`${API_URL}/api/characters/${id}`, {
credentials: "include",
});
if (!response.ok) {
return null;
}
const data = await response.json();
return { ...data, id: data._id || data.id };
} catch (error) {
console.error("Failed to get character:", error);
return null;
}
}
export async function createPlayerCharacter(
character: Omit<PlayerCharacter, "id" | "userId" | "createdAt" | "updatedAt">,
): Promise<PlayerCharacter | null> {
try {
const response = await fetch(`${API_URL}/api/characters`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(character),
});
if (!response.ok) {
throw new Error("Failed to create character");
}
const data = await response.json();
return { ...data, id: data._id };
} catch (error) {
console.error("Failed to create character:", error);
return null;
}
}
export async function updatePlayerCharacter(
id: string,
character: Partial<PlayerCharacter>,
): Promise<boolean> {
try {
const response = await fetch(`${API_URL}/api/characters/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(character),
});
return response.ok;
} catch (error) {
console.error("Failed to update character:", error);
return false;
}
}
export async function deletePlayerCharacter(id: string): Promise<boolean> {
try {
const response = await fetch(`${API_URL}/api/characters/${id}`, {
method: "DELETE",
credentials: "include",
});
return response.ok;
} catch (error) {
console.error("Failed to delete character:", error);
return false;
}
}
+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);
}
+96
View File
@@ -0,0 +1,96 @@
// Сервис для хранения данных в localStorage
import type { Story, GameSession } from "../types";
const STORIES_KEY = "resekai_stories";
const SESSIONS_KEY = "resekai_sessions";
// Истории
export function getStories(): Story[] {
const data = localStorage.getItem(STORIES_KEY);
if (!data) return [];
const stories = JSON.parse(data);
return stories.map((s: Story) => ({
...s,
createdAt: new Date(s.createdAt),
updatedAt: new Date(s.updatedAt),
}));
}
export function getStoryById(id: string): Story | undefined {
const stories = getStories();
return stories.find((s) => s.id === id);
}
export function saveStory(story: Story): void {
const stories = getStories();
const index = stories.findIndex((s) => s.id === story.id);
if (index >= 0) {
stories[index] = story;
} else {
stories.push(story);
}
localStorage.setItem(STORIES_KEY, JSON.stringify(stories));
}
export function deleteStory(id: string): void {
const stories = getStories().filter((s) => s.id !== id);
localStorage.setItem(STORIES_KEY, JSON.stringify(stories));
// Удаляем связанные сессии
const sessions = getSessions().filter((s) => s.storyId !== id);
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
}
// Игровые сессии
export function getSessions(): GameSession[] {
const data = localStorage.getItem(SESSIONS_KEY);
if (!data) return [];
const sessions = JSON.parse(data);
return sessions.map((s: GameSession) => ({
...s,
createdAt: new Date(s.createdAt),
updatedAt: new Date(s.updatedAt),
messages: s.messages.map((m) => ({
...m,
timestamp: new Date(m.timestamp),
})),
}));
}
export function getSessionByStoryId(storyId: string): GameSession | undefined {
const sessions = getSessions();
return sessions.find((s) => s.storyId === storyId);
}
export function getSessionById(id: string): GameSession | undefined {
const sessions = getSessions();
return sessions.find((s) => s.id === id);
}
export function saveSession(session: GameSession): void {
const sessions = getSessions();
const index = sessions.findIndex((s) => s.id === session.id);
if (index >= 0) {
sessions[index] = session;
} else {
sessions.push(session);
}
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
}
export function deleteSession(id: string): void {
const sessions = getSessions().filter((s) => s.id !== id);
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
}
// Генерация ID
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}