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 = [];
}
};