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(null); const [sessionsList, setSessionsList] = useState([]); const [currentSessionId, setCurrentSessionId] = useState(null); const [session, setSession] = useState(null); const [playerCharacter, setPlayerCharacter] = useState(null); const [input, setInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const [isInitialLoading, setIsInitialLoading] = useState(true); const [error, setError] = useState(null); const [streamingContent, setStreamingContent] = useState(""); const [showSessionMenu, setShowSessionMenu] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false); const [editingMessageId, setEditingMessageId] = useState(null); const [editContent, setEditContent] = useState(""); const abortControllerRef = useRef(null); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const inputRef = useRef(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 (

Загрузка игры...

); } if (!story) { return (

История не найдена

← Вернуться к списку
); } return (

{story.title}

{showSessionMenu && (
Сессии
{sessionsList.map((s) => (
{sessionsList.length > 1 && ( )}
))}
)}
🎟️ {formatTokens(estimateTokens(session?.messages || []))}
{session?.messages.map((message) => (
{editingMessageId === message.id ? (