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
const streamingBufferRef = useRef("");
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)
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 = [];
}
};
+16 -7
View File
@@ -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<string> {
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;