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:
+108
-14
@@ -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 = [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user