Fix AI rule drift and add auto-save during streaming

- Add rule reminders after 10+ messages to prevent AI drift
- Add auto-save every 5 seconds during streaming
- Add beforeunload warning for unsaved changes
- Save user message immediately before generating AI response
- Use refs for latest session data in async operations
- Reduce summary threshold from 20 to 15 messages
This commit is contained in:
Alexej Wolff
2026-05-04 18:44:10 +02:00
parent 5e98c60e3e
commit f73a218745
2 changed files with 124 additions and 21 deletions
+108 -14
View File
@@ -72,6 +72,28 @@ export default function GamePage() {
// Refs for throttled streaming updates // Refs for throttled streaming updates
const streamingBufferRef = useRef(""); const streamingBufferRef = useRef("");
const lastUpdateRef = useRef(0); const lastUpdateRef = useRef(0);
// Ref to track latest session for auto-save
const sessionRef = useRef<GameSession | null>(null);
const pendingMessagesRef = useRef<ChatMessage[]>([]);
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) // Throttled streaming update (every 50ms instead of every chunk)
const updateStreamingContent = useCallback((chunk: string) => { const updateStreamingContent = useCallback((chunk: string) => {
@@ -120,7 +142,7 @@ export default function GamePage() {
// Если персонаж не указан в URL, загружаем первого доступного (или избранного) // Если персонаж не указан в URL, загружаем первого доступного (или избранного)
const characters = await getPlayerCharacters(); const characters = await getPlayerCharacters();
if (characters.length > 0) { if (characters.length > 0) {
character = characters.find(c => c.isFavorite) || characters[0]; character = characters.find((c) => c.isFavorite) || characters[0];
setPlayerCharacter(character); setPlayerCharacter(character);
} }
} }
@@ -145,7 +167,12 @@ export default function GamePage() {
} }
// Если сессия пустая (нет сообщений) — запускаем историю // Если сессия пустая (нет сообщений) — запускаем историю
if (sessionData.messages.length === 0 && character) { if (sessionData.messages.length === 0 && character) {
startStory(normalizedStory, sessionData, character, latestSession.id); startStory(
normalizedStory,
sessionData,
character,
latestSession.id,
);
} }
} }
} else if (characterId) { } else if (characterId) {
@@ -293,6 +320,8 @@ export default function GamePage() {
const updatedMessages = [...session.messages, userMessage]; const updatedMessages = [...session.messages, userMessage];
const tempSession = { ...session, messages: updatedMessages }; const tempSession = { ...session, messages: updatedMessages };
setSession(tempSession); setSession(tempSession);
pendingMessagesRef.current = updatedMessages;
hasUnsavedChangesRef.current = true;
setInput(""); setInput("");
// Reset textarea height // Reset textarea height
if (inputRef.current) { if (inputRef.current) {
@@ -302,10 +331,39 @@ export default function GamePage() {
setError(null); setError(null);
setStreamingContent(""); setStreamingContent("");
streamingBufferRef.current = ""; 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 для возможности отмены // Создаём AbortController для возможности отмены
abortControllerRef.current = new 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 { try {
// Streaming ответ от AI // Streaming ответ от AI
const response = await generateStoryResponseStream( const response = await generateStoryResponseStream(
@@ -318,6 +376,7 @@ export default function GamePage() {
abortControllerRef.current.signal, abortControllerRef.current.signal,
); );
flushStreamingContent(); flushStreamingContent();
clearInterval(autoSaveInterval);
const assistantMessage: ChatMessage = { const assistantMessage: ChatMessage = {
id: generateId(), id: generateId(),
@@ -334,9 +393,9 @@ export default function GamePage() {
session.keyEvents || [], session.keyEvents || [],
); );
// Генерируем сводку каждые 20 сообщений // Генерируем сводку каждые 15 сообщений
let newSummary = session.storySummary; let newSummary = session.storySummary;
if (allMessages.length % 20 === 0 && allMessages.length > 0) { if (allMessages.length % 15 === 0 && allMessages.length > 0) {
newSummary = await generateStorySummary( newSummary = await generateStorySummary(
story, story,
allMessages, allMessages,
@@ -351,20 +410,18 @@ export default function GamePage() {
storySummary: newSummary, storySummary: newSummary,
}; };
await apiSaveSession( await apiSaveSession(story.id, currentSessionId, finalSession);
story.id,
currentSessionId,
finalSession,
);
setSession(finalSession); setSession(finalSession);
hasUnsavedChangesRef.current = false;
} catch (err) { } catch (err) {
clearInterval(autoSaveInterval);
if (err instanceof Error && err.name === "AbortError") { if (err instanceof Error && err.name === "AbortError") {
// Пользователь отменил — сохраняем то что получили // Пользователь отменил — сохраняем то что получили
if (streamingContent.trim()) { if (streamingBufferRef.current.trim()) {
const partialMessage: ChatMessage = { const partialMessage: ChatMessage = {
id: generateId(), id: generateId(),
role: "assistant", role: "assistant",
content: streamingContent, content: streamingBufferRef.current,
timestamp: new Date(), timestamp: new Date(),
}; };
const partialSession: GameSession = { const partialSession: GameSession = {
@@ -373,6 +430,7 @@ export default function GamePage() {
}; };
await apiSaveSession(story.id, currentSessionId, partialSession); await apiSaveSession(story.id, currentSessionId, partialSession);
setSession(partialSession); setSession(partialSession);
hasUnsavedChangesRef.current = false;
} }
} else { } else {
setError(err instanceof Error ? err.message : "Произошла ошибка"); setError(err instanceof Error ? err.message : "Произошла ошибка");
@@ -383,6 +441,7 @@ export default function GamePage() {
setIsLoading(false); setIsLoading(false);
setStreamingContent(""); setStreamingContent("");
abortControllerRef.current = null; abortControllerRef.current = null;
pendingMessagesRef.current = [];
inputRef.current?.focus(); inputRef.current?.focus();
} }
}; };
@@ -470,6 +529,8 @@ export default function GamePage() {
const tempSession = { ...session, messages: updatedMessages }; const tempSession = { ...session, messages: updatedMessages };
setSession(tempSession); setSession(tempSession);
pendingMessagesRef.current = updatedMessages;
hasUnsavedChangesRef.current = true;
setEditingMessageId(null); setEditingMessageId(null);
setEditContent(""); setEditContent("");
setIsLoading(true); setIsLoading(true);
@@ -479,6 +540,34 @@ export default function GamePage() {
abortControllerRef.current = new AbortController(); 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 { try {
// Генерируем новый ответ ИИ // Генерируем новый ответ ИИ
const response = await generateStoryResponseStream( const response = await generateStoryResponseStream(
@@ -491,6 +580,7 @@ export default function GamePage() {
abortControllerRef.current.signal, abortControllerRef.current.signal,
); );
flushStreamingContent(); flushStreamingContent();
clearInterval(autoSaveInterval);
// Сохраняем ответ ИИ в текущую версию // Сохраняем ответ ИИ в текущую версию
const finalVersions: MessageVersion[] = [...newVersions]; const finalVersions: MessageVersion[] = [...newVersions];
@@ -524,14 +614,16 @@ export default function GamePage() {
await apiSaveSession(story.id, currentSessionId, finalSession); await apiSaveSession(story.id, currentSessionId, finalSession);
setSession(finalSession); setSession(finalSession);
hasUnsavedChangesRef.current = false;
} catch (err) { } catch (err) {
clearInterval(autoSaveInterval);
if (err instanceof Error && err.name === "AbortError") { if (err instanceof Error && err.name === "AbortError") {
if (streamingContent.trim()) { if (streamingBufferRef.current.trim()) {
// Сохраняем частичный ответ в версию // Сохраняем частичный ответ в версию
const finalVersions: MessageVersion[] = [...newVersions]; const finalVersions: MessageVersion[] = [...newVersions];
finalVersions[newActiveVersion] = { finalVersions[newActiveVersion] = {
...finalVersions[newActiveVersion], ...finalVersions[newActiveVersion],
aiResponse: streamingContent, aiResponse: streamingBufferRef.current,
}; };
const finalUserMessage: ChatMessage = { const finalUserMessage: ChatMessage = {
@@ -542,7 +634,7 @@ export default function GamePage() {
const partialMessage: ChatMessage = { const partialMessage: ChatMessage = {
id: generateId(), id: generateId(),
role: "assistant", role: "assistant",
content: streamingContent, content: streamingBufferRef.current,
timestamp: new Date(), timestamp: new Date(),
}; };
const partialSession: GameSession = { const partialSession: GameSession = {
@@ -551,6 +643,7 @@ export default function GamePage() {
}; };
await apiSaveSession(story.id, currentSessionId, partialSession); await apiSaveSession(story.id, currentSessionId, partialSession);
setSession(partialSession); setSession(partialSession);
hasUnsavedChangesRef.current = false;
} }
} else { } else {
setError(err instanceof Error ? err.message : "Произошла ошибка"); setError(err instanceof Error ? err.message : "Произошла ошибка");
@@ -559,6 +652,7 @@ export default function GamePage() {
setIsLoading(false); setIsLoading(false);
setStreamingContent(""); setStreamingContent("");
abortControllerRef.current = null; abortControllerRef.current = null;
pendingMessagesRef.current = [];
} }
}; };
+16 -7
View File
@@ -11,7 +11,7 @@ const DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";
// Context settings // Context settings
const RECENT_MESSAGES_COUNT = 6; // Last N messages for context 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 // API key should be stored in environment variables
const getApiKey = () => import.meta.env.VITE_DEEPSEEK_API_KEY || ""; 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 state = session.currentState;
const summary = session.storySummary || "The story just began."; const summary = session.storySummary || "The story just began.";
const keyEvents = session.keyEvents?.length const keyEvents = session.keyEvents?.length
? session.keyEvents.slice(-5).join("\n- ") ? session.keyEvents.slice(-5).join("\n- ")
: "No significant events yet."; : "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 ` return `
=== CURRENT STATE === === CURRENT STATE ===
Location: ${state.location} Location: ${state.location}
@@ -288,7 +297,7 @@ Inventory: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Empty"}
${summary} ${summary}
=== KEY EVENTS === === KEY EVENTS ===
- ${keyEvents}`; - ${keyEvents}${ruleReminder}`;
} }
/** /**
@@ -317,8 +326,8 @@ export async function generateStoryResponse(
// 2. World context (cached by DeepSeek) // 2. World context (cached by DeepSeek)
const worldContext = buildWorldContext(story); const worldContext = buildWorldContext(story);
// 3. Dynamic context (state + summary) // 3. Dynamic context (state + summary + rule reminders after 10+ messages)
const dynamicContext = session ? buildDynamicContext(session) : ""; const dynamicContext = session ? buildDynamicContext(session, chatHistory.length) : "";
// 4. Last N messages (not the full history!) // 4. Last N messages (not the full history!)
const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT); const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT);
@@ -353,7 +362,7 @@ export async function generateStoryResponseStream(
): Promise<string> { ): Promise<string> {
const styleRules = buildStyleRules(story, player); const styleRules = buildStyleRules(story, player);
const worldContext = buildWorldContext(story); const worldContext = buildWorldContext(story);
const dynamicContext = session ? buildDynamicContext(session) : ""; const dynamicContext = session ? buildDynamicContext(session, chatHistory.length) : "";
const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT); const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT);
const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext; const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext;