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 { saveSession as apiSaveSession } from "../services/api"; import { generateStoryResponseStream, buildSystemPrompt, sendMessage, generateStorySummary, extractKeyEvents, updateWorldState, shouldUpdateWorldState, } from "../services/deepseek"; import type { Story, GameSession, ChatMessage, MessageVersion, PlayerCharacter, WorldState, } from "../types"; import { useGameSession, useStreamingResponse, useCharacterDetection, useLazyMessages, } from "../hooks"; import { SessionSelector, CharacterPanel, StreamingText, } from "../components/game"; import "./GamePage.css"; function generateId(): string { return Math.random().toString(36).substring(2) + Date.now().toString(36); } 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(); // Game session hook const { story, session, sessionsList, currentSessionId, playerCharacter, isInitialLoading, setSession, createNewSession, switchSession, removeSession, markUnsaved, } = useGameSession({ storyId: id, characterIdFromUrl: searchParams.get("character"), isAuthenticated, }); // Streaming response hook const { streamingContent, updateStreamingContent, flushStreamingContent, resetStreaming, startStreaming, abortController, // abort - не используется, т.к. кнопка "Остановить" закомментирована getLatestContent, } = useStreamingResponse(); // Character detection hook const lastAssistantContent = session?.messages ?.slice() .reverse() .find((m) => m.role === "assistant")?.content; const { activeCharacter } = useCharacterDetection( story?.characters || [], lastAssistantContent, streamingContent, ); // Lazy messages hook for performance const { visibleMessages, hasHiddenMessages, hiddenCount, loadMore: loadMoreMessages, handleScroll: handleLazyScroll, totalCount, } = useLazyMessages(session?.messages, currentSessionId); // Track previous totalCount to detect new messages (start at 0 to trigger initial scroll) const prevTotalCountRef = useRef(0); // Local state const [input, setInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const [isContinuing, setIsContinuing] = useState(false); const [error, setError] = useState(null); const [showScrollButton, setShowScrollButton] = useState(false); const [editingMessageId, setEditingMessageId] = useState(null); const [editContent, setEditContent] = useState(""); const [isOocMode, setIsOocMode] = useState(false); // Refs const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const inputRef = useRef(null); const sessionRef = useRef(null); const pendingMessagesRef = useRef([]); // Keep sessionRef in sync useEffect(() => { sessionRef.current = session; }, [session]); // Warn before leaving during loading useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { if (isLoading) { e.preventDefault(); e.returnValue = ""; } }; window.addEventListener("beforeunload", handleBeforeUnload); return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [isLoading]); // Initial load effect - start story if needed useEffect(() => { const initStory = async () => { if (!story || !session || !currentSessionId || !playerCharacter) return; if (session.messages.length === 0) { startStory(story, session, playerCharacter, currentSessionId); } }; initStory(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [story, session?.messages?.length, currentSessionId, playerCharacter]); // Scroll effects - scroll to bottom only when NEW messages are added useEffect(() => { // Only scroll if totalCount increased (new message added) // Don't scroll when loading more old messages (which increases visibleMessages but not totalCount) if (totalCount > prevTotalCountRef.current) { const timer = setTimeout(() => scrollToBottom(), 50); prevTotalCountRef.current = totalCount; return () => clearTimeout(timer); } // Don't update ref here - only update when we actually scroll or on session change }, [totalCount, currentSessionId]); // Auto-scroll during streaming useEffect(() => { if (isLoading && streamingContent) { // Use instant scroll during streaming to avoid lag messagesEndRef.current?.scrollIntoView({ behavior: "auto" }); } }, [isLoading, streamingContent]); useEffect(() => { if (session && !isInitialLoading) { // Reset prevTotalCountRef for new session and scroll to bottom prevTotalCountRef.current = session.messages?.length || 0; // Scroll multiple times with delays to ensure DOM is ready const t1 = setTimeout(() => scrollToBottom(true), 100); const t2 = setTimeout(() => scrollToBottom(true), 300); return () => { clearTimeout(t1); clearTimeout(t2); }; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentSessionId, isInitialLoading]); const scrollToBottom = (instant = false) => { // Try scrollIntoView first messagesEndRef.current?.scrollIntoView({ behavior: instant ? "auto" : "smooth", }); // Also set scrollTop as fallback if (messagesContainerRef.current) { messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; } }; const handleScroll = () => { const container = messagesContainerRef.current; if (!container) return; const { scrollTop, scrollHeight, clientHeight } = container; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; setShowScrollButton(distanceFromBottom > 200); // Lazy load more messages when scrolling up handleLazyScroll(container); }; const startStory = async ( storyData: Story, sessionData: GameSession, character: PlayerCharacter | null, sessionId: string, ) => { if (!character) return; if (storyData.firstMessage?.trim()) { 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: isOocMode ? `[OOC: ${input.trim()}]` : input.trim(), timestamp: new Date(), }; const updatedMessages = [...session.messages, userMessage]; const tempSession = { ...session, messages: updatedMessages }; setSession(tempSession); pendingMessagesRef.current = updatedMessages; markUnsaved(); setInput(""); if (inputRef.current) { inputRef.current.style.height = "auto"; } setIsLoading(true); setError(null); const signal = startStreaming(); // Immediately save user message try { await apiSaveSession(story.id, currentSessionId, tempSession); } catch { // Continue even if initial save fails } // Auto-save interval during streaming const autoSaveInterval = setInterval(async () => { if (abortController.current && story && currentSessionId) { const currentContent = document.querySelector(".message.streaming .message-content") ?.textContent || ""; if (currentContent.trim()) { const partialMessage: ChatMessage = { id: generateId(), role: "assistant", content: currentContent, timestamp: new Date(), }; const partialSession: GameSession = { ...sessionRef.current!, messages: [...pendingMessagesRef.current, partialMessage], }; try { await apiSaveSession(story.id, currentSessionId, partialSession); } catch { // Ignore save errors during streaming } } } }, 5000); try { const response = await generateStoryResponseStream( story, session.messages, userMessage.content, // Use userMessage.content to include [OOC: ...] wrapper updateStreamingContent, playerCharacter || undefined, session, signal, ); flushStreamingContent(); clearInterval(autoSaveInterval); const assistantMessage: ChatMessage = { id: generateId(), role: "assistant", content: response, timestamp: new Date(), }; const allMessages = [...updatedMessages, assistantMessage]; // Skip state updates for OOC messages - they are meta-conversations let newKeyEvents = session.keyEvents || []; let newSummary = session.storySummary; let newWorldState = session.worldState; if (!isOocMode) { newKeyEvents = await extractKeyEvents( response, session.keyEvents || [], ); // Calculate total context size const totalContextSize = allMessages.reduce( (sum, m) => sum + m.content.length, 0, ); const contextThreshold = 100000; // Generate summary when context gets large // Generate summary more frequently (every 8 messages) OR when context is large const shouldGenerateSummary = (allMessages.length % 8 === 0 && allMessages.length > 0) || (totalContextSize > contextThreshold && allMessages.length > 10); if (shouldGenerateSummary) { console.log( `Generating summary: ${allMessages.length} messages, ${totalContextSize} chars`, ); newSummary = await generateStorySummary( story, allMessages, session.storySummary, ); } // Update world state for better character tracking if (shouldUpdateWorldState(allMessages.length, session.worldState)) { try { newWorldState = await updateWorldState( story, allMessages, session.worldState, ); } catch (e) { console.warn("Failed to update world state:", e); } } } const finalSession: GameSession = { ...session, messages: allMessages, keyEvents: newKeyEvents, storySummary: newSummary, worldState: newWorldState, }; await apiSaveSession(story.id, currentSessionId, finalSession); setSession(finalSession); } catch (err) { clearInterval(autoSaveInterval); if (err instanceof Error && err.name === "AbortError") { // User cancelled — save what we got (use getLatestContent for fresh buffer) const currentContent = getLatestContent(); if (currentContent.trim()) { const partialMessage: ChatMessage = { id: generateId(), role: "assistant", content: currentContent, 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); resetStreaming(); pendingMessagesRef.current = []; inputRef.current?.focus(); } }; // Закомментирована кнопка остановки - функция сохранена на будущее // const handleStop = () => { // abort(); // }; // Retry: regenerate last AI response const handleRetry = async () => { if (!story || !session || !currentSessionId || isLoading) return; if (session.messages.length < 2) return; // Find last assistant message and corresponding user message const messages = session.messages; let lastAssistantIndex = -1; for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === "assistant") { lastAssistantIndex = i; break; } } if (lastAssistantIndex < 1) return; // Need at least user + assistant const lastUserMessage = messages[lastAssistantIndex - 1]; if (lastUserMessage.role !== "user") return; // Remove last assistant message, keep everything before it const messagesWithoutLast = messages.slice(0, lastAssistantIndex); const tempSession = { ...session, messages: messagesWithoutLast }; setSession(tempSession); pendingMessagesRef.current = messagesWithoutLast; markUnsaved(); setIsLoading(true); setError(null); const signal = startStreaming(); try { const response = await generateStoryResponseStream( story, messagesWithoutLast.slice(0, -1), // History without the user message we're regenerating for lastUserMessage.content, updateStreamingContent, playerCharacter || undefined, tempSession, signal, ); flushStreamingContent(); const newAssistantMessage: ChatMessage = { id: generateId(), role: "assistant", content: response, timestamp: new Date(), }; const finalMessages = [...messagesWithoutLast, newAssistantMessage]; // Skip state updates for OOC const isOoc = lastUserMessage.content.startsWith("[OOC:"); let newWorldState = session.worldState; if ( !isOoc && shouldUpdateWorldState(finalMessages.length, session.worldState) ) { try { newWorldState = await updateWorldState( story, finalMessages, session.worldState, ); } catch (e) { console.warn("Failed to update world state:", e); } } const finalSession: GameSession = { ...session, messages: finalMessages, worldState: newWorldState, }; await apiSaveSession(story.id, currentSessionId, finalSession); setSession(finalSession); } catch (err) { if (err instanceof Error && err.name === "AbortError") { const currentContent = getLatestContent(); if (currentContent.trim()) { const partialMessage: ChatMessage = { id: generateId(), role: "assistant", content: currentContent, timestamp: new Date(), }; const partialSession: GameSession = { ...session, messages: [...messagesWithoutLast, partialMessage], }; await apiSaveSession(story.id, currentSessionId, partialSession); setSession(partialSession); } } else { setError(err instanceof Error ? err.message : "Произошла ошибка"); setSession(session); // Restore original } } finally { setIsLoading(false); resetStreaming(); pendingMessagesRef.current = []; } }; // Erase: remove last message pair (user + assistant) const handleErase = async () => { if (!story || !session || !currentSessionId || isLoading) return; if (session.messages.length < 2) return; const messages = session.messages; let removeCount = 0; // Remove last assistant if exists if (messages[messages.length - 1].role === "assistant") { removeCount++; // Also remove the user message before it if ( messages.length >= 2 && messages[messages.length - 2].role === "user" ) { removeCount++; } } else if (messages[messages.length - 1].role === "user") { // Just remove the user message removeCount = 1; } if (removeCount === 0) return; const newMessages = messages.slice(0, -removeCount); const newSession: GameSession = { ...session, messages: newMessages, }; try { await apiSaveSession(story.id, currentSessionId, newSession); setSession(newSession); markUnsaved(); } catch (err) { setError(err instanceof Error ? err.message : "Ошибка удаления"); } }; // Continue: продолжить генерацию истории (дополняет последнее сообщение ИИ) const handleContinue = async () => { if (isLoading || !session || !story || !currentSessionId) return; if (session.messages.length === 0) return; // Find last assistant message const messages = session.messages; let lastAssistantIndex = -1; for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === "assistant") { lastAssistantIndex = i; break; } } if (lastAssistantIndex === -1) return; // No assistant message to continue const lastAssistantMessage = messages[lastAssistantIndex]; setIsLoading(true); setIsContinuing(true); setError(null); const signal = startStreaming(); try { // Generate continuation without adding user message // Pass special instruction to continue from where AI left off const response = await generateStoryResponseStream( story, messages.slice(0, lastAssistantIndex + 1), // History INCLUDING the message we're continuing "[CONTINUE]", // Special marker for continuation updateStreamingContent, playerCharacter || undefined, { ...session, messages: messages.slice(0, lastAssistantIndex + 1) }, signal, ); flushStreamingContent(); // Append to existing assistant message const updatedAssistantMessage: ChatMessage = { ...lastAssistantMessage, content: lastAssistantMessage.content + "\n\n" + response, }; const finalMessages = [ ...messages.slice(0, lastAssistantIndex), updatedAssistantMessage, ...messages.slice(lastAssistantIndex + 1), ]; const finalSession: GameSession = { ...session, messages: finalMessages, }; await apiSaveSession(story.id, currentSessionId, finalSession); setSession(finalSession); } catch (err) { if (err instanceof Error && err.name === "AbortError") { const currentContent = getLatestContent(); if (currentContent.trim()) { // Still append partial content const updatedAssistantMessage: ChatMessage = { ...lastAssistantMessage, content: lastAssistantMessage.content + "\n\n" + currentContent, }; const partialMessages = [ ...messages.slice(0, lastAssistantIndex), updatedAssistantMessage, ...messages.slice(lastAssistantIndex + 1), ]; const partialSession: GameSession = { ...session, messages: partialMessages, }; await apiSaveSession(story.id, currentSessionId, partialSession); setSession(partialSession); } } else { setError(err instanceof Error ? err.message : "Произошла ошибка"); } } finally { setIsLoading(false); setIsContinuing(false); resetStreaming(); } }; 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, }, ]; const currentVersionIdx = message.activeVersion || 0; if ( versions[currentVersionIdx] && !versions[currentVersionIdx].aiResponse && currentAiResponse ) { versions[currentVersionIdx] = { ...versions[currentVersionIdx], aiResponse: currentAiResponse, }; } 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); pendingMessagesRef.current = updatedMessages; markUnsaved(); setEditingMessageId(null); setEditContent(""); setIsLoading(true); setError(null); const signal = startStreaming(); try { await apiSaveSession(story.id, currentSessionId, tempSession); } catch { // Continue even if save fails } const autoSaveInterval = setInterval(async () => { if (abortController.current && story && currentSessionId) { const currentContent = getLatestContent(); if (currentContent.trim()) { const partialMessage: ChatMessage = { id: generateId(), role: "assistant", content: currentContent, timestamp: new Date(), }; const partialSession: GameSession = { ...sessionRef.current!, messages: [...pendingMessagesRef.current, partialMessage], }; try { await apiSaveSession(story.id, currentSessionId, partialSession); } catch { // Ignore } } } }, 5000); // Reset worldState — it's now stale (was calculated from future messages) let recalculatedWorldState: WorldState | undefined = undefined; try { recalculatedWorldState = await updateWorldState( story, messagesUpToEdit, undefined, // pass undefined — force fresh calculation, ignore old state ); } catch (e) { console.warn("WorldState recalculation failed on edit:", e); // Continue without worldState — better than using stale future state } const sessionForEdit: GameSession = { ...session, messages: messagesUpToEdit, worldState: recalculatedWorldState, }; try { const response = await generateStoryResponseStream( story, messagesUpToEdit, editContent.trim(), updateStreamingContent, playerCharacter || undefined, sessionForEdit, signal, ); flushStreamingContent(); clearInterval(autoSaveInterval); 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, ]; // Update world state after edit regeneration (based on recalculated state) let newWorldState = recalculatedWorldState; if (shouldUpdateWorldState(allMessages.length, recalculatedWorldState)) { try { newWorldState = await updateWorldState( story, allMessages, recalculatedWorldState, ); } catch (e) { console.warn("Failed to update world state:", e); } } const finalSession: GameSession = { ...session, messages: allMessages, worldState: newWorldState, }; await apiSaveSession(story.id, currentSessionId, finalSession); setSession(finalSession); } catch (err) { clearInterval(autoSaveInterval); if (err instanceof Error && err.name === "AbortError") { const currentContent = getLatestContent(); if (currentContent.trim()) { const finalVersions: MessageVersion[] = [...newVersions]; finalVersions[newActiveVersion] = { ...finalVersions[newActiveVersion], aiResponse: currentContent, }; const finalUserMessage: ChatMessage = { ...updatedUserMessage, versions: finalVersions, }; const partialMessage: ChatMessage = { id: generateId(), role: "assistant", content: currentContent, 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); resetStreaming(); pendingMessagesRef.current = []; } }; 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, }; const 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; const characterId = playerCharacter?.id || session?.playerId; if (!characterId) { window.location.href = `/story/${id}`; return; } // Create new session - the existing effect will start the story // when session state updates with 0 messages await createNewSession(characterId); }; const handleSwitchSession = async (sessionId: string) => { if (sessionId === currentSessionId) return; await switchSession(sessionId); }; const handleDeleteSession = async ( sessionId: string, sessionName: string, ) => { const confirmed = confirm(`Удалить сессию "${sessionName}"?`); if (!confirmed) return; await removeSession(sessionId); }; const currentSessionName = sessionsList.find((s) => s.id === currentSessionId)?.name || "Сессия"; if (isInitialLoading) { return (

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

); } if (!story) { return (

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

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

{story.title}

🎟️ {formatTokens(estimateTokens(session?.messages || []))}
{hasHiddenMessages && ( )} {/* Filter out pending user message until streaming starts, but NOT during Continue */} {(isLoading && !streamingContent && !isContinuing ? visibleMessages.slice(0, -1) : visibleMessages ).map((message, index) => { // Check if this is the last assistant message const isLastAssistant = message.role === "assistant" && index === visibleMessages.length - 1; const isContinuingThis = isLastAssistant && isContinuing && streamingContent; return (
{editingMessageId === message.id ? (