diff --git a/src/pages/GamePage.tsx b/src/pages/GamePage.tsx index 65dfd27..b59c524 100644 --- a/src/pages/GamePage.tsx +++ b/src/pages/GamePage.tsx @@ -72,6 +72,28 @@ export default function GamePage() { // Refs for throttled streaming updates const streamingBufferRef = useRef(""); const lastUpdateRef = useRef(0); + // Ref to track latest session for auto-save + const sessionRef = useRef(null); + const pendingMessagesRef = useRef([]); + const lastAutoSaveRef = useRef(0); + const hasUnsavedChangesRef = useRef(false); + + // Keep sessionRef in sync + useEffect(() => { + sessionRef.current = session; + }, [session]); + + // Warn before leaving if there are unsaved changes + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (hasUnsavedChangesRef.current || isLoading) { + e.preventDefault(); + e.returnValue = ''; + } + }; + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [isLoading]); // Throttled streaming update (every 50ms instead of every chunk) const updateStreamingContent = useCallback((chunk: string) => { @@ -120,7 +142,7 @@ export default function GamePage() { // Если персонаж не указан в URL, загружаем первого доступного (или избранного) const characters = await getPlayerCharacters(); if (characters.length > 0) { - character = characters.find(c => c.isFavorite) || characters[0]; + character = characters.find((c) => c.isFavorite) || characters[0]; setPlayerCharacter(character); } } @@ -145,7 +167,12 @@ export default function GamePage() { } // Если сессия пустая (нет сообщений) — запускаем историю if (sessionData.messages.length === 0 && character) { - startStory(normalizedStory, sessionData, character, latestSession.id); + startStory( + normalizedStory, + sessionData, + character, + latestSession.id, + ); } } } else if (characterId) { @@ -293,6 +320,8 @@ export default function GamePage() { const updatedMessages = [...session.messages, userMessage]; const tempSession = { ...session, messages: updatedMessages }; setSession(tempSession); + pendingMessagesRef.current = updatedMessages; + hasUnsavedChangesRef.current = true; setInput(""); // Reset textarea height if (inputRef.current) { @@ -302,10 +331,39 @@ export default function GamePage() { setError(null); setStreamingContent(""); streamingBufferRef.current = ""; + lastAutoSaveRef.current = Date.now(); + + // Immediately save user message + try { + await apiSaveSession(story.id, currentSessionId, tempSession); + } catch (e) { + // Continue even if initial save fails + } // Создаём AbortController для возможности отмены abortControllerRef.current = new AbortController(); + // Auto-save interval during streaming + const autoSaveInterval = setInterval(async () => { + if (streamingBufferRef.current.trim() && story && currentSessionId) { + const partialMessage: ChatMessage = { + id: generateId(), + role: "assistant", + content: streamingBufferRef.current, + timestamp: new Date(), + }; + const partialSession: GameSession = { + ...sessionRef.current!, + messages: [...pendingMessagesRef.current, partialMessage], + }; + try { + await apiSaveSession(story.id, currentSessionId, partialSession); + } catch (e) { + // Ignore save errors during streaming + } + } + }, 5000); // Auto-save every 5 seconds + try { // Streaming ответ от AI const response = await generateStoryResponseStream( @@ -318,6 +376,7 @@ export default function GamePage() { abortControllerRef.current.signal, ); flushStreamingContent(); + clearInterval(autoSaveInterval); const assistantMessage: ChatMessage = { id: generateId(), @@ -334,9 +393,9 @@ export default function GamePage() { session.keyEvents || [], ); - // Генерируем сводку каждые 20 сообщений + // Генерируем сводку каждые 15 сообщений let newSummary = session.storySummary; - if (allMessages.length % 20 === 0 && allMessages.length > 0) { + if (allMessages.length % 15 === 0 && allMessages.length > 0) { newSummary = await generateStorySummary( story, allMessages, @@ -351,20 +410,18 @@ export default function GamePage() { storySummary: newSummary, }; - await apiSaveSession( - story.id, - currentSessionId, - finalSession, - ); + await apiSaveSession(story.id, currentSessionId, finalSession); setSession(finalSession); + hasUnsavedChangesRef.current = false; } catch (err) { + clearInterval(autoSaveInterval); if (err instanceof Error && err.name === "AbortError") { // Пользователь отменил — сохраняем то что получили - if (streamingContent.trim()) { + if (streamingBufferRef.current.trim()) { const partialMessage: ChatMessage = { id: generateId(), role: "assistant", - content: streamingContent, + content: streamingBufferRef.current, timestamp: new Date(), }; const partialSession: GameSession = { @@ -373,6 +430,7 @@ export default function GamePage() { }; await apiSaveSession(story.id, currentSessionId, partialSession); setSession(partialSession); + hasUnsavedChangesRef.current = false; } } else { setError(err instanceof Error ? err.message : "Произошла ошибка"); @@ -383,6 +441,7 @@ export default function GamePage() { setIsLoading(false); setStreamingContent(""); abortControllerRef.current = null; + pendingMessagesRef.current = []; inputRef.current?.focus(); } }; @@ -470,6 +529,8 @@ export default function GamePage() { const tempSession = { ...session, messages: updatedMessages }; setSession(tempSession); + pendingMessagesRef.current = updatedMessages; + hasUnsavedChangesRef.current = true; setEditingMessageId(null); setEditContent(""); setIsLoading(true); @@ -479,6 +540,34 @@ export default function GamePage() { abortControllerRef.current = new AbortController(); + // Save immediately + try { + await apiSaveSession(story.id, currentSessionId, tempSession); + } catch (e) { + // Continue even if initial save fails + } + + // Auto-save interval during streaming + const autoSaveInterval = setInterval(async () => { + if (streamingBufferRef.current.trim() && story && currentSessionId) { + const partialMessage: ChatMessage = { + id: generateId(), + role: "assistant", + content: streamingBufferRef.current, + timestamp: new Date(), + }; + const partialSession: GameSession = { + ...sessionRef.current!, + messages: [...pendingMessagesRef.current, partialMessage], + }; + try { + await apiSaveSession(story.id, currentSessionId, partialSession); + } catch (e) { + // Ignore save errors during streaming + } + } + }, 5000); + try { // Генерируем новый ответ ИИ const response = await generateStoryResponseStream( @@ -491,6 +580,7 @@ export default function GamePage() { abortControllerRef.current.signal, ); flushStreamingContent(); + clearInterval(autoSaveInterval); // Сохраняем ответ ИИ в текущую версию const finalVersions: MessageVersion[] = [...newVersions]; @@ -524,14 +614,16 @@ export default function GamePage() { await apiSaveSession(story.id, currentSessionId, finalSession); setSession(finalSession); + hasUnsavedChangesRef.current = false; } catch (err) { + clearInterval(autoSaveInterval); if (err instanceof Error && err.name === "AbortError") { - if (streamingContent.trim()) { + if (streamingBufferRef.current.trim()) { // Сохраняем частичный ответ в версию const finalVersions: MessageVersion[] = [...newVersions]; finalVersions[newActiveVersion] = { ...finalVersions[newActiveVersion], - aiResponse: streamingContent, + aiResponse: streamingBufferRef.current, }; const finalUserMessage: ChatMessage = { @@ -542,7 +634,7 @@ export default function GamePage() { const partialMessage: ChatMessage = { id: generateId(), role: "assistant", - content: streamingContent, + content: streamingBufferRef.current, timestamp: new Date(), }; const partialSession: GameSession = { @@ -551,6 +643,7 @@ export default function GamePage() { }; await apiSaveSession(story.id, currentSessionId, partialSession); setSession(partialSession); + hasUnsavedChangesRef.current = false; } } else { setError(err instanceof Error ? err.message : "Произошла ошибка"); @@ -559,6 +652,7 @@ export default function GamePage() { setIsLoading(false); setStreamingContent(""); abortControllerRef.current = null; + pendingMessagesRef.current = []; } }; diff --git a/src/services/deepseek.ts b/src/services/deepseek.ts index ea17e7e..c76aa52 100644 --- a/src/services/deepseek.ts +++ b/src/services/deepseek.ts @@ -11,7 +11,7 @@ const DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions"; // Context settings const RECENT_MESSAGES_COUNT = 6; // Last N messages for context -const SUMMARY_THRESHOLD = 20; // After how many messages to generate summary +const SUMMARY_THRESHOLD = 15; // After how many messages to generate summary // API key should be stored in environment variables const getApiKey = () => import.meta.env.VITE_DEEPSEEK_API_KEY || ""; @@ -269,15 +269,24 @@ ${story.plot}`; } /** - * Builds dynamic context (state + summary) + * Builds dynamic context (state + summary + rule reminders) */ -export function buildDynamicContext(session: GameSession): string { +export function buildDynamicContext(session: GameSession, messageCount?: number): string { const state = session.currentState; const summary = session.storySummary || "The story just began."; const keyEvents = session.keyEvents?.length ? session.keyEvents.slice(-5).join("\n- ") : "No significant events yet."; + // Add rule reminders after 10+ messages to prevent drift + const ruleReminder = (messageCount && messageCount >= 10) ? ` + +=== REMINDER === +• Do NOT act for the player — only describe reactions and consequences +• Do NOT ask "What do you do?" — end with atmosphere, not questions +• Format dialogue: **"text"** (double asterisks = bold) +• React to player's words explicitly` : ''; + return ` === CURRENT STATE === Location: ${state.location} @@ -288,7 +297,7 @@ Inventory: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Empty"} ${summary} === KEY EVENTS === -- ${keyEvents}`; +- ${keyEvents}${ruleReminder}`; } /** @@ -317,8 +326,8 @@ export async function generateStoryResponse( // 2. World context (cached by DeepSeek) const worldContext = buildWorldContext(story); - // 3. Dynamic context (state + summary) - const dynamicContext = session ? buildDynamicContext(session) : ""; + // 3. Dynamic context (state + summary + rule reminders after 10+ messages) + const dynamicContext = session ? buildDynamicContext(session, chatHistory.length) : ""; // 4. Last N messages (not the full history!) const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT); @@ -353,7 +362,7 @@ export async function generateStoryResponseStream( ): Promise { const styleRules = buildStyleRules(story, player); const worldContext = buildWorldContext(story); - const dynamicContext = session ? buildDynamicContext(session) : ""; + const dynamicContext = session ? buildDynamicContext(session, chatHistory.length) : ""; const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT); const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext;