Files
ReSekai/src/pages/GamePage.tsx
T

917 lines
31 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from "react";
import { useParams, Link, useSearchParams } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import { useAuth } from "../contexts/AuthContext";
import {
getStory,
getSessionsList,
getSession,
createSession,
saveSession as apiSaveSession,
deleteSession as apiDeleteSession,
getPlayerCharacter,
type SessionListItem,
} from "../services/api";
import {
generateStoryResponseStream,
buildSystemPrompt,
sendMessage,
generateStorySummary,
extractKeyEvents,
} from "../services/deepseek";
import type {
Story,
GameSession,
ChatMessage,
PlayerCharacter,
MessageVersion,
} from "../types";
import "./GamePage.css";
function generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
// Примерный подсчёт токенов (1 токен ≈ 3 символа для русского)
function estimateTokens(messages: ChatMessage[]): number {
if (!messages || messages.length === 0) return 0;
const totalChars = messages.reduce((sum, msg) => sum + msg.content.length, 0);
return Math.round(totalChars / 3);
}
function formatTokens(tokens: number): string {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
return tokens.toString();
}
export default function GamePage() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const { isAuthenticated } = useAuth();
const [story, setStory] = useState<Story | null>(null);
const [sessionsList, setSessionsList] = useState<SessionListItem[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [session, setSession] = useState<GameSession | null>(null);
const [playerCharacter, setPlayerCharacter] =
useState<PlayerCharacter | null>(null);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [streamingContent, setStreamingContent] = useState("");
const [showSessionMenu, setShowSessionMenu] = useState(false);
const [showScrollButton, setShowScrollButton] = useState(false);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const abortControllerRef = useRef<AbortController | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const loadGame = async () => {
if (!id || !isAuthenticated) {
setIsInitialLoading(false);
return;
}
const foundStory = await getStory(id);
if (foundStory) {
const normalizedStory = {
...foundStory,
id: (foundStory as any)._id || foundStory.id,
};
setStory(normalizedStory);
// Загружаем список сессий
const sessions = await getSessionsList(id);
console.log("[GamePage] Sessions list:", sessions);
setSessionsList(sessions);
const characterId = searchParams.get("character");
// Загружаем персонажа
let character: PlayerCharacter | null = null;
if (characterId) {
character = await getPlayerCharacter(characterId);
setPlayerCharacter(character);
}
// Если есть сессии, загружаем последнюю (или создаём новую)
if (sessions.length > 0) {
const latestSession = sessions[0];
console.log("[GamePage] Loading latest session:", latestSession);
setCurrentSessionId(latestSession.id);
const sessionData = await getSession(id, latestSession.id);
console.log("[GamePage] Session data loaded:", sessionData);
if (sessionData) {
setSession(sessionData);
// Загружаем персонажа из сессии если не выбран
if (!character && sessionData.playerId) {
character = await getPlayerCharacter(sessionData.playerId);
setPlayerCharacter(character);
}
}
} else if (characterId) {
// Нет сессий и выбран персонаж — создаём новую
const newSession = await createSession(id, undefined, characterId);
if (newSession) {
setSessionsList([newSession]);
setCurrentSessionId(newSession.id);
const sessionData = await getSession(id, newSession.id);
if (sessionData) {
setSession(sessionData);
if (character) {
startStory(
normalizedStory,
sessionData,
character,
newSession.id,
);
}
}
}
}
}
setIsInitialLoading(false);
};
loadGame();
}, [id, isAuthenticated, searchParams]);
// Обновляем историю при возврате на страницу (после редактирования)
useEffect(() => {
const handleFocus = async () => {
if (!id || !isAuthenticated || isInitialLoading) return;
const updatedStory = await getStory(id);
if (updatedStory) {
const normalizedStory = {
...updatedStory,
id: (updatedStory as any)._id || updatedStory.id,
};
setStory(normalizedStory);
console.log("[GamePage] История обновлена после возврата на страницу");
}
};
window.addEventListener("focus", handleFocus);
return () => window.removeEventListener("focus", handleFocus);
}, [id, isAuthenticated, isInitialLoading]);
useEffect(() => {
scrollToBottom();
}, [session?.messages]);
// Scroll to bottom on session load
useEffect(() => {
if (session && !isInitialLoading) {
setTimeout(() => scrollToBottom(), 100);
}
}, [currentSessionId, isInitialLoading]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const handleScroll = () => {
const container = messagesContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
setShowScrollButton(distanceFromBottom > 200);
};
const startStory = async (
storyData: Story,
sessionData: GameSession,
character: PlayerCharacter,
sessionId: string,
) => {
// Если есть заготовленное первое сообщение, используем его
if (storyData.firstMessage && storyData.firstMessage.trim()) {
// Заменяем {user} на имя персонажа
const firstMessageContent = storyData.firstMessage.replace(
/\{user\}/gi,
character.name,
);
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: firstMessageContent,
timestamp: new Date(),
};
const updatedSession: GameSession = {
...sessionData,
messages: [assistantMessage],
};
await apiSaveSession(storyData.id, sessionId, updatedSession);
setSession(updatedSession);
return;
}
// Иначе генерируем через ИИ
setIsLoading(true);
setError(null);
try {
const systemPrompt = buildSystemPrompt(storyData, character);
const response = await sendMessage([
{ role: "system", content: systemPrompt },
{ role: "user", content: "Начни историю." },
]);
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: response,
timestamp: new Date(),
};
const updatedSession: GameSession = {
...sessionData,
messages: [assistantMessage],
};
await apiSaveSession(storyData.id, sessionId, updatedSession);
setSession(updatedSession);
} catch (err) {
setError(err instanceof Error ? err.message : "Произошла ошибка");
} finally {
setIsLoading(false);
}
};
const handleSend = async () => {
if (!input.trim() || !story || !session || !currentSessionId || isLoading)
return;
const userMessage: ChatMessage = {
id: generateId(),
role: "user",
content: input.trim(),
timestamp: new Date(),
};
const updatedMessages = [...session.messages, userMessage];
const tempSession = { ...session, messages: updatedMessages };
setSession(tempSession);
setInput("");
// Reset textarea height
if (inputRef.current) {
inputRef.current.style.height = "auto";
}
setIsLoading(true);
setError(null);
setStreamingContent("");
// Создаём AbortController для возможности отмены
abortControllerRef.current = new AbortController();
try {
// Streaming ответ от AI
const response = await generateStoryResponseStream(
story,
session.messages,
input.trim(),
(chunk) => {
setStreamingContent((prev) => prev + chunk);
},
playerCharacter || undefined,
session,
abortControllerRef.current.signal,
);
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: response,
timestamp: new Date(),
};
const allMessages = [...updatedMessages, assistantMessage];
// Обновляем ключевые события
const newKeyEvents = await extractKeyEvents(
response,
session.keyEvents || [],
);
// Генерируем сводку каждые 20 сообщений
let newSummary = session.storySummary;
if (allMessages.length % 20 === 0 && allMessages.length > 0) {
console.log("[GamePage] Generating story summary...");
newSummary = await generateStorySummary(
story,
allMessages,
session.storySummary,
);
}
const finalSession: GameSession = {
...session,
messages: allMessages,
keyEvents: newKeyEvents,
storySummary: newSummary,
};
const saved = await apiSaveSession(
story.id,
currentSessionId,
finalSession,
);
console.log(
"[GamePage] Session saved:",
saved,
"Messages:",
allMessages.length,
);
setSession(finalSession);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// Пользователь отменил — сохраняем то что получили
if (streamingContent.trim()) {
const partialMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: streamingContent,
timestamp: new Date(),
};
const partialSession: GameSession = {
...session,
messages: [...updatedMessages, partialMessage],
};
await apiSaveSession(story.id, currentSessionId, partialSession);
setSession(partialSession);
}
} else {
setError(err instanceof Error ? err.message : "Произошла ошибка");
setSession(session);
setInput(userMessage.content);
}
} finally {
setIsLoading(false);
setStreamingContent("");
abortControllerRef.current = null;
inputRef.current?.focus();
}
};
const handleStop = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Начать редактирование сообщения
const handleEditMessage = (messageId: string, content: string) => {
setEditingMessageId(messageId);
setEditContent(content);
};
// Отменить редактирование
const handleCancelEdit = () => {
setEditingMessageId(null);
setEditContent("");
};
// Сохранить редактирование и регенерировать ответ
const handleSaveEdit = async (messageId: string) => {
if (!session || !story || !currentSessionId || !editContent.trim()) return;
const messageIndex = session.messages.findIndex((m) => m.id === messageId);
if (messageIndex === -1) return;
const message = session.messages[messageIndex];
// Находим следующее сообщение ИИ (если есть)
const nextMessage = session.messages[messageIndex + 1];
const currentAiResponse = nextMessage?.role === "assistant" ? nextMessage.content : undefined;
// Инициализируем версии если их нет (первая версия = оригинал с текущим ответом ИИ)
const versions: MessageVersion[] = message.versions || [{
content: message.content,
timestamp: message.timestamp,
aiResponse: currentAiResponse
}];
// Если текущая версия не имеет aiResponse, добавляем его
const currentVersionIdx = message.activeVersion || 0;
if (versions[currentVersionIdx] && !versions[currentVersionIdx].aiResponse && currentAiResponse) {
versions[currentVersionIdx] = { ...versions[currentVersionIdx], aiResponse: currentAiResponse };
}
// Добавляем новую версию (aiResponse добавится после генерации)
const newVersion: MessageVersion = { content: editContent.trim(), timestamp: new Date() };
const newVersions: MessageVersion[] = [...versions, newVersion];
const newActiveVersion = newVersions.length - 1;
// Обновляем сообщение пользователя
const updatedUserMessage: ChatMessage = {
...message,
content: editContent.trim(),
versions: newVersions,
activeVersion: newActiveVersion,
};
// Обрезаем историю до этого сообщения (удаляем последующие)
const messagesUpToEdit = session.messages.slice(0, messageIndex);
const updatedMessages = [...messagesUpToEdit, updatedUserMessage];
const tempSession = { ...session, messages: updatedMessages };
setSession(tempSession);
setEditingMessageId(null);
setEditContent("");
setIsLoading(true);
setError(null);
setStreamingContent("");
abortControllerRef.current = new AbortController();
try {
// Генерируем новый ответ ИИ
const response = await generateStoryResponseStream(
story,
messagesUpToEdit,
editContent.trim(),
(chunk) => {
setStreamingContent((prev) => prev + chunk);
},
playerCharacter || undefined,
session,
abortControllerRef.current.signal,
);
// Сохраняем ответ ИИ в текущую версию
const finalVersions: MessageVersion[] = [...newVersions];
finalVersions[newActiveVersion] = { ...finalVersions[newActiveVersion], aiResponse: response };
const finalUserMessage: ChatMessage = {
...updatedUserMessage,
versions: finalVersions,
};
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: response,
timestamp: new Date(),
};
const allMessages = [...messagesUpToEdit, finalUserMessage, assistantMessage];
const finalSession: GameSession = {
...session,
messages: allMessages,
};
await apiSaveSession(story.id, currentSessionId, finalSession);
setSession(finalSession);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
if (streamingContent.trim()) {
// Сохраняем частичный ответ в версию
const finalVersions: MessageVersion[] = [...newVersions];
finalVersions[newActiveVersion] = { ...finalVersions[newActiveVersion], aiResponse: streamingContent };
const finalUserMessage: ChatMessage = {
...updatedUserMessage,
versions: finalVersions,
};
const partialMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: streamingContent,
timestamp: new Date(),
};
const partialSession: GameSession = {
...session,
messages: [...messagesUpToEdit, finalUserMessage, partialMessage],
};
await apiSaveSession(story.id, currentSessionId, partialSession);
setSession(partialSession);
}
} else {
setError(err instanceof Error ? err.message : "Произошла ошибка");
}
} finally {
setIsLoading(false);
setStreamingContent("");
abortControllerRef.current = null;
}
};
// Переключить версию сообщения
const handleSwitchVersion = async (messageId: string, direction: "prev" | "next") => {
if (!session || !story || !currentSessionId) return;
const messageIndex = session.messages.findIndex((m) => m.id === messageId);
if (messageIndex === -1) return;
const message = session.messages[messageIndex];
if (!message.versions || message.versions.length <= 1) return;
const currentVersion = message.activeVersion || 0;
let newVersion: number;
if (direction === "prev") {
newVersion = currentVersion > 0 ? currentVersion - 1 : message.versions.length - 1;
} else {
newVersion = currentVersion < message.versions.length - 1 ? currentVersion + 1 : 0;
}
// Сохраняем текущий ответ ИИ в текущую версию перед переключением
const nextMessage = session.messages[messageIndex + 1];
const currentAiResponse = nextMessage?.role === "assistant" ? nextMessage.content : undefined;
const updatedVersions: MessageVersion[] = [...message.versions];
if (currentAiResponse && updatedVersions[currentVersion]) {
updatedVersions[currentVersion] = { ...updatedVersions[currentVersion], aiResponse: currentAiResponse };
}
const selectedVersion = updatedVersions[newVersion];
const updatedMessage: ChatMessage = {
...message,
content: selectedVersion.content,
versions: updatedVersions,
activeVersion: newVersion,
};
let updatedMessages = [...session.messages];
updatedMessages[messageIndex] = updatedMessage;
// Если у версии есть сохраненный ответ ИИ, обновляем следующее сообщение
if (selectedVersion.aiResponse && nextMessage?.role === "assistant") {
const updatedAiMessage: ChatMessage = {
...nextMessage,
content: selectedVersion.aiResponse,
};
updatedMessages[messageIndex + 1] = updatedAiMessage;
}
const updatedSession = { ...session, messages: updatedMessages };
setSession(updatedSession);
// Сохраняем изменения
await apiSaveSession(story.id, currentSessionId, updatedSession);
};
// Функции управления сессиями
const handleCreateNewSession = async () => {
if (!story || !id) return;
setShowSessionMenu(false);
// Используем персонажа из текущей сессии или выбранного
const characterId = playerCharacter?.id || session?.playerId;
if (!characterId) {
alert("Не выбран персонаж для игры");
return;
}
const name = `Сессия ${sessionsList.length + 1}`;
const newSession = await createSession(id, name, characterId);
if (newSession) {
setSessionsList([newSession, ...sessionsList]);
setCurrentSessionId(newSession.id);
const sessionData = await getSession(id, newSession.id);
if (sessionData) {
setSession(sessionData);
// Загружаем персонажа если нет
let character = playerCharacter;
if (!character && characterId) {
character = await getPlayerCharacter(characterId);
setPlayerCharacter(character);
}
if (character) {
startStory(story, sessionData, character, newSession.id);
}
}
}
};
const handleSwitchSession = async (sessionId: string) => {
console.log("[GamePage] Switching to session:", sessionId, "current:", currentSessionId);
if (!id || sessionId === currentSessionId) {
setShowSessionMenu(false);
return;
}
setShowSessionMenu(false);
setIsInitialLoading(true);
const sessionData = await getSession(id, sessionId);
console.log("[GamePage] Loaded session data:", sessionData);
if (sessionData) {
setCurrentSessionId(sessionId);
setSession(sessionData);
// Загружаем персонажа сессии
if (sessionData.playerId) {
const character = await getPlayerCharacter(sessionData.playerId);
setPlayerCharacter(character);
}
}
setIsInitialLoading(false);
};
const handleDeleteSession = async (sessionId: string) => {
if (!id || sessionsList.length <= 1) {
alert("Нельзя удалить единственную сессию");
return;
}
if (!confirm("Удалить эту сессию? Это действие нельзя отменить.")) {
return;
}
const success = await apiDeleteSession(id, sessionId);
if (success) {
const updatedList = sessionsList.filter((s) => s.id !== sessionId);
setSessionsList(updatedList);
// Если удалили текущую сессию — переключаемся на другую
if (sessionId === currentSessionId && updatedList.length > 0) {
await handleSwitchSession(updatedList[0].id);
}
}
setShowSessionMenu(false);
};
const currentSessionName =
sessionsList.find((s) => s.id === currentSessionId)?.name || "Сессия";
if (isInitialLoading) {
return (
<div className="game-page">
<div className="game-loading">
<p>Загрузка игры...</p>
</div>
</div>
);
}
if (!story) {
return (
<div className="game-page">
<div className="game-error">
<h2>История не найдена</h2>
<Link to="/" className="back-link">
Вернуться к списку
</Link>
</div>
</div>
);
}
return (
<div className="game-page">
<header className="game-header">
<Link to={`/story/${story.id}`} className="back-btn">
</Link>
<div className="header-info">
<h1>{story.title}</h1>
<div className="header-session">
<button
className="session-selector"
onClick={() => setShowSessionMenu(!showSessionMenu)}
>
📖 {currentSessionName}
</button>
{showSessionMenu && (
<div className="session-menu">
<div className="session-menu-header">
<span>Сессии</span>
<button
className="new-session-btn"
onClick={handleCreateNewSession}
title="Новая сессия"
>
</button>
</div>
<div className="session-list">
{sessionsList.map((s) => (
<div
key={s.id}
className={`session-item ${s.id === currentSessionId ? "active" : ""}`}
>
<button
className="session-name"
onClick={() => handleSwitchSession(s.id)}
>
{s.name}
<span className="session-messages">
{s.messagesCount} сообщ.
</span>
</button>
{sessionsList.length > 1 && (
<button
className="delete-session-btn"
onClick={(e) => {
e.stopPropagation();
handleDeleteSession(s.id);
}}
title="Удалить сессию"
>
🗑
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
<div className="header-stats">
<span className="stat-badge tokens">
🎟 {formatTokens(estimateTokens(session?.messages || []))}
</span>
</div>
</header>
<div className="game-content">
<div
className="messages-container"
ref={messagesContainerRef}
onScroll={handleScroll}
>
{session?.messages.map((message) => (
<div key={message.id} className={`message ${message.role}`}>
{editingMessageId === message.id ? (
<div className="message-edit-form">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="message-edit-textarea"
autoFocus
/>
<div className="message-edit-actions">
<button
className="edit-cancel-btn"
onClick={handleCancelEdit}
>
Отмена
</button>
<button
className="edit-save-btn"
onClick={() => handleSaveEdit(message.id)}
>
Сохранить и переиграть
</button>
</div>
</div>
) : (
<>
<div className="message-content">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
<div className="message-footer">
<span className="message-time">
{new Date(message.timestamp).toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
{message.role === "user" && !isLoading && (
<div className="message-actions">
{message.versions && message.versions.length > 1 && (
<div className="version-switcher">
<button
className="version-btn"
onClick={() => handleSwitchVersion(message.id, "prev")}
>
</button>
<span className="version-indicator">
{(message.activeVersion || 0) + 1}/{message.versions.length}
</span>
<button
className="version-btn"
onClick={() => handleSwitchVersion(message.id, "next")}
>
</button>
</div>
)}
<button
className="edit-btn"
onClick={() => handleEditMessage(message.id, message.content)}
title="Редактировать"
>
</button>
</div>
)}
</div>
</>
)}
</div>
))}
{isLoading && streamingContent && (
<div className="message assistant streaming">
<div className="message-content">
<ReactMarkdown>{streamingContent}</ReactMarkdown>
</div>
</div>
)}
{isLoading && !streamingContent && (
<div className="message assistant loading">
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
)}
{error && (
<div className="error-message">
<span> {error}</span>
<button onClick={() => setError(null)}></button>
</div>
)}
<div ref={messagesEndRef} />
</div>
{showScrollButton && (
<button className="scroll-to-bottom-btn" onClick={scrollToBottom}>
</button>
)}
{/* RPG кнопки скрыты — раскомментировать при необходимости
<div className="quick-actions">
<button onClick={() => handleQuickAction("Осмотреться вокруг")}>
👀 Осмотреться
</button>
<button onClick={() => handleQuickAction("Проверить инвентарь")}>
🎒 Инвентарь
</button>
<button onClick={() => handleQuickAction("Поговорить с кем-нибудь")}>
💬 Говорить
</button>
<button onClick={() => handleQuickAction("Идти вперёд")}>
🚶 Идти
</button>
</div>
*/}
<form className="input-container" onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
// Auto-resize
e.target.style.height = "auto";
e.target.style.height = Math.min(e.target.scrollHeight, 150) + "px";
}}
onKeyDown={handleKeyDown}
placeholder="Что ты хочешь сделать?..."
disabled={isLoading}
rows={1}
name="chat-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
enterKeyHint="send"
data-form-type="other"
data-lpignore="true"
data-gramm="false"
/>
{isLoading ? (
<button type="button" onClick={handleStop} className="send-btn stop-btn">
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="send-btn"
>
</button>
)}
</form>
</div>
</div>
);
}