feat: WorldState memory, increased context, better error handling

- Added WorldState types for character location tracking
- Increased RECENT_MESSAGES_COUNT from 6 to 15
- Increased server limits (50k/200k chars)
- Added language reminders to system prompts
- Better error logging for 400 errors
This commit is contained in:
Alexej Wolff
2026-05-06 21:57:22 +02:00
parent 0b62e73111
commit 77b2794eb1
4 changed files with 251 additions and 19 deletions
+6 -4
View File
@@ -143,8 +143,8 @@ function pickAllowedFields(obj, allowedFields) {
const DEEPSEEK_LIMITS = { const DEEPSEEK_LIMITS = {
MAX_TOKENS_LIMIT: 4096, MAX_TOKENS_LIMIT: 4096,
MAX_MESSAGES: 100, MAX_MESSAGES: 100,
MAX_MESSAGE_LENGTH: 32000, // ~8k tokens per message MAX_MESSAGE_LENGTH: 50000, // ~12k tokens per message (increased for long context)
MAX_TOTAL_LENGTH: 128000, // ~32k tokens total MAX_TOTAL_LENGTH: 200000, // ~50k tokens total (increased for world state + summary)
RATE_LIMIT_WINDOW_MS: 60 * 1000, // 1 minute RATE_LIMIT_WINDOW_MS: 60 * 1000, // 1 minute
RATE_LIMIT_MAX_REQUESTS: 20, // 20 requests per minute per user RATE_LIMIT_MAX_REQUESTS: 20, // 20 requests per minute per user
}; };
@@ -1196,6 +1196,7 @@ app.post("/api/deepseek/chat", requireAuth, async (req, res) => {
// Validation // Validation
const validationErrors = validateDeepSeekRequest(req.body); const validationErrors = validateDeepSeekRequest(req.body);
if (validationErrors.length > 0) { if (validationErrors.length > 0) {
console.warn("DeepSeek chat validation failed:", validationErrors);
return res return res
.status(400) .status(400)
.json({ error: "Validation failed", details: validationErrors }); .json({ error: "Validation failed", details: validationErrors });
@@ -1206,7 +1207,7 @@ app.post("/api/deepseek/chat", requireAuth, async (req, res) => {
return res.status(500).json({ error: "DeepSeek API key not configured" }); return res.status(500).json({ error: "DeepSeek API key not configured" });
} }
const { messages, temperature = 0.8, max_tokens = 1000 } = req.body; const { temperature = 0.8, max_tokens = 1000 } = req.body;
const sanitizedMessages = sanitizeDeepSeekMessages(messages); const sanitizedMessages = sanitizeDeepSeekMessages(messages);
const clampedMaxTokens = Math.min( const clampedMaxTokens = Math.min(
max_tokens, max_tokens,
@@ -1256,6 +1257,7 @@ app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => {
// Validation // Validation
const validationErrors = validateDeepSeekRequest(req.body); const validationErrors = validateDeepSeekRequest(req.body);
if (validationErrors.length > 0) { if (validationErrors.length > 0) {
console.warn("DeepSeek stream validation failed:", validationErrors);
return res return res
.status(400) .status(400)
.json({ error: "Validation failed", details: validationErrors }); .json({ error: "Validation failed", details: validationErrors });
@@ -1266,7 +1268,7 @@ app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => {
return res.status(500).json({ error: "DeepSeek API key not configured" }); return res.status(500).json({ error: "DeepSeek API key not configured" });
} }
const { messages, temperature = 0.8, max_tokens = 1000 } = req.body; const { temperature = 0.8, max_tokens = 1000 } = req.body;
const sanitizedMessages = sanitizeDeepSeekMessages(messages); const sanitizedMessages = sanitizeDeepSeekMessages(messages);
const clampedMaxTokens = Math.min( const clampedMaxTokens = Math.min(
max_tokens, max_tokens,
+32
View File
@@ -9,6 +9,8 @@ import {
sendMessage, sendMessage,
generateStorySummary, generateStorySummary,
extractKeyEvents, extractKeyEvents,
updateWorldState,
shouldUpdateWorldState,
} from "../services/deepseek"; } from "../services/deepseek";
import type { import type {
Story, Story,
@@ -359,11 +361,26 @@ export default function GamePage() {
); );
} }
// Update world state for better character tracking
let newWorldState = session.worldState;
if (shouldUpdateWorldState(allMessages.length, session.worldState)) {
try {
newWorldState = await updateWorldState(
story,
allMessages,
session.worldState,
);
} catch (e) {
console.warn("Failed to update world state:", e);
}
}
const finalSession: GameSession = { const finalSession: GameSession = {
...session, ...session,
messages: allMessages, messages: allMessages,
keyEvents: newKeyEvents, keyEvents: newKeyEvents,
storySummary: newSummary, storySummary: newSummary,
worldState: newWorldState,
}; };
await apiSaveSession(story.id, currentSessionId, finalSession); await apiSaveSession(story.id, currentSessionId, finalSession);
@@ -545,9 +562,24 @@ export default function GamePage() {
assistantMessage, assistantMessage,
]; ];
// Update world state after edit regeneration
let newWorldState = session.worldState;
if (shouldUpdateWorldState(allMessages.length, session.worldState)) {
try {
newWorldState = await updateWorldState(
story,
allMessages,
session.worldState,
);
} catch (e) {
console.warn("Failed to update world state:", e);
}
}
const finalSession: GameSession = { const finalSession: GameSession = {
...session, ...session,
messages: allMessages, messages: allMessages,
worldState: newWorldState,
}; };
await apiSaveSession(story.id, currentSessionId, finalSession); await apiSaveSession(story.id, currentSessionId, finalSession);
+189 -15
View File
@@ -5,13 +5,15 @@ import type {
ChatMessage, ChatMessage,
PlayerCharacter, PlayerCharacter,
GameSession, GameSession,
WorldState,
} from "../types"; } from "../types";
const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001"; const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001";
// Context settings // Context settings
const RECENT_MESSAGES_COUNT = 6; // Last N messages for context const RECENT_MESSAGES_COUNT = 15; // Last N messages for context (increased for better scene continuity)
const SUMMARY_THRESHOLD = 15; // After how many messages to generate summary const SUMMARY_THRESHOLD = 20; // After how many messages to generate summary
const WORLD_STATE_UPDATE_INTERVAL = 5; // Update world state every N messages
interface DeepSeekMessage { interface DeepSeekMessage {
role: "system" | "user" | "assistant"; role: "system" | "user" | "assistant";
@@ -51,7 +53,17 @@ export async function sendMessage(
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`DeepSeek API error: ${response.status}`); let errorDetails = "";
try {
const errorData = await response.json();
errorDetails = errorData.details?.join(", ") || errorData.error || "";
} catch {
// Ignore parse errors
}
console.error("DeepSeek API error:", response.status, errorDetails);
throw new Error(
`DeepSeek API error: ${response.status}${errorDetails ? ` - ${errorDetails}` : ""}`,
);
} }
const data: DeepSeekResponse = await response.json(); const data: DeepSeekResponse = await response.json();
@@ -82,7 +94,17 @@ export async function sendMessageStream(
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`DeepSeek API error: ${response.status}`); let errorDetails = "";
try {
const errorData = await response.json();
errorDetails = errorData.details?.join(", ") || errorData.error || "";
} catch {
// Ignore parse errors
}
console.error("DeepSeek stream error:", response.status, errorDetails);
throw new Error(
`DeepSeek API error: ${response.status}${errorDetails ? ` - ${errorDetails}` : ""}`,
);
} }
const reader = response.body?.getReader(); const reader = response.body?.getReader();
@@ -177,6 +199,8 @@ LANGUAGE: ${story.language}
GENRE: ${story.genre.join(", ")} GENRE: ${story.genre.join(", ")}
SETTING: ${settingInfo} SETTING: ${settingInfo}
IMPORTANT: Respond ONLY in language: ${story.language}. Use proper grammar and spelling.
=== PLAYER CHARACTER === === PLAYER CHARACTER ===
Name: ${player?.name || "Hero"} Name: ${player?.name || "Hero"}
Description: ${playerDescription}`; Description: ${playerDescription}`;
@@ -262,12 +286,11 @@ ${story.plot}`;
} }
/** /**
* Builds dynamic context (state + summary + rule reminders) * Builds dynamic context (state + summary + world state + rule reminders)
*/ */
export function buildDynamicContext( export function buildDynamicContext(
session: GameSession, session: GameSession,
messageCount?: number, messageCount?: number,
hasCustomRules?: boolean,
): string { ): 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.";
@@ -275,24 +298,29 @@ export function buildDynamicContext(
? session.keyEvents.slice(-5).join("\n- ") ? session.keyEvents.slice(-5).join("\n- ")
: "No significant events yet."; : "No significant events yet.";
// World state for character tracking
const worldStateContext = formatWorldStateForContext(session.worldState);
// Add rule reminders after 10+ messages to prevent drift // Add rule reminders after 10+ messages to prevent drift
// Skip if story has custom rules - they take priority
const ruleReminder = const ruleReminder =
messageCount && messageCount >= 10 && !hasCustomRules messageCount && messageCount >= 10
? ` ? `
=== REMINDER === === REMINDER ===
• Maintain scene continuity — remember current location, characters present, and ongoing actions
• Do NOT act for the player — only describe reactions and consequences • Do NOT act for the player — only describe reactions and consequences
• Do NOT ask "What do you do?" — end with atmosphere, not questions • Do NOT ask "What do you do?" — end with atmosphere, not questions
• Format dialogue: **"text"** (double asterisks = bold) • Format dialogue: **"text"** (double asterisks = bold)
• React to player's words explicitly` • React to player's words explicitly
• Use proper grammar and spelling in the story language
• Characters can only be where they logically should be based on their last known location`
: ""; : "";
return ` return `
=== CURRENT STATE === === PLAYER STATUS ===
Location: ${state.location}
Health: ${state.health}% Health: ${state.health}%
Inventory: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Empty"} Inventory: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Empty"}
${worldStateContext}
=== STORY SUMMARY === === STORY SUMMARY ===
${summary} ${summary}
@@ -328,9 +356,8 @@ export async function generateStoryResponse(
const worldContext = buildWorldContext(story); const worldContext = buildWorldContext(story);
// 3. Dynamic context (state + summary + rule reminders after 10+ messages) // 3. Dynamic context (state + summary + rule reminders after 10+ messages)
const hasCustomRules = Boolean(story.narrativeRules?.trim());
const dynamicContext = session const dynamicContext = session
? buildDynamicContext(session, chatHistory.length, hasCustomRules) ? buildDynamicContext(session, chatHistory.length)
: ""; : "";
// 4. Last N messages (not the full history!) // 4. Last N messages (not the full history!)
@@ -366,9 +393,8 @@ 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 hasCustomRules = Boolean(story.narrativeRules?.trim());
const dynamicContext = session const dynamicContext = session
? buildDynamicContext(session, chatHistory.length, hasCustomRules) ? 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;
@@ -532,3 +558,151 @@ The description should be intriguing and make the reader want to start the adven
return sendMessage(messages, 0.7); return sendMessage(messages, 0.7);
} }
/**
* Updates world state by analyzing recent messages
* Call this every WORLD_STATE_UPDATE_INTERVAL messages
*/
export async function updateWorldState(
story: Story,
messages: ChatMessage[],
currentWorldState?: WorldState,
): Promise<WorldState> {
const messageCount = messages.length;
// Get last N messages for analysis
const recentMessages = messages.slice(-10);
const conversationText = recentMessages
.map(
(m, i) =>
`[${messages.length - 10 + i + 1}] ${m.role === "user" ? "Player" : "Narrator"}: ${m.content}`,
)
.join("\n\n");
// Get story characters for reference
const storyCharacters = story.characters.map((c) => c.name).join(", ");
const currentStateJson = currentWorldState
? JSON.stringify(currentWorldState, null, 2)
: "null";
const prompt = `Analyze the recent story messages and update the world state.
STORY CHARACTERS: ${storyCharacters || "Not specified"}
PLAYER CHARACTER: Main character (MC/Hero)
CURRENT WORLD STATE:
${currentStateJson}
RECENT MESSAGES:
${conversationText}
Based on the messages, output ONLY a valid JSON object with this exact structure:
{
"currentScene": {
"location": "Current location of the player (be specific)",
"presentCharacters": ["List of character names currently in the scene with player"],
"situation": "Brief description of what's happening now (1 sentence)"
},
"characters": {
"CharacterName": {
"location": "Where this character is now",
"lastSeenMessage": <message number when last seen>,
"status": "What they're doing",
"notes": "Important details (optional)"
}
},
"lastUpdated": ${messageCount}
}
RULES:
- Only include characters that have appeared in the story
- If a character left the scene, track their destination
- presentCharacters = only those physically with the player RIGHT NOW
- Be consistent with previous state, update only what changed
- Output ONLY the JSON, no explanations`;
const worldStateMessages: DeepSeekMessage[] = [
{
role: "system",
content:
"You are a story state tracker. Analyze story messages and output world state as JSON. Be precise about character locations and movements. Output ONLY valid JSON.",
},
{ role: "user", content: prompt },
];
try {
const response = await sendMessage(worldStateMessages, 0.2, 800);
// Extract JSON from response (in case there's extra text)
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
console.warn("Failed to extract JSON from world state response");
return currentWorldState || createDefaultWorldState(messageCount);
}
const parsed = JSON.parse(jsonMatch[0]) as WorldState;
return parsed;
} catch (error) {
console.error("Failed to update world state:", error);
return currentWorldState || createDefaultWorldState(messageCount);
}
}
/**
* Creates a default world state
*/
function createDefaultWorldState(messageCount: number): WorldState {
return {
currentScene: {
location: "Unknown",
presentCharacters: [],
situation: "The story has just begun.",
},
characters: {},
lastUpdated: messageCount,
};
}
/**
* Checks if world state should be updated
*/
export function shouldUpdateWorldState(
messageCount: number,
currentWorldState?: WorldState,
): boolean {
if (!currentWorldState) return messageCount >= WORLD_STATE_UPDATE_INTERVAL;
const messagesSinceUpdate = messageCount - currentWorldState.lastUpdated;
return messagesSinceUpdate >= WORLD_STATE_UPDATE_INTERVAL;
}
/**
* Formats world state for inclusion in context
*/
export function formatWorldStateForContext(worldState?: WorldState): string {
if (!worldState) return "";
const { currentScene, characters } = worldState;
let result = `
=== CURRENT SCENE ===
Location: ${currentScene.location}
Present: ${currentScene.presentCharacters.length > 0 ? currentScene.presentCharacters.join(", ") : "No one nearby"}
Situation: ${currentScene.situation}`;
const characterEntries = Object.entries(characters);
if (characterEntries.length > 0) {
result += `
=== CHARACTER LOCATIONS ===`;
for (const [name, state] of characterEntries) {
// Skip characters in current scene
if (currentScene.presentCharacters.includes(name)) continue;
result += `
${name}: ${state.location} (${state.status})`;
}
}
return result;
}
+24
View File
@@ -69,6 +69,28 @@ export interface MessageVersion {
aiResponse?: string; // соответствующий ответ ИИ для этой версии aiResponse?: string; // соответствующий ответ ИИ для этой версии
} }
// Состояние персонажа в мире (для памяти ИИ)
export interface CharacterState {
location: string; // Где персонаж сейчас
lastSeenMessage: number; // Номер сообщения когда последний раз видели
status: string; // Что делает ("Едет в город", "Ждёт у таверны")
notes?: string; // Дополнительные заметки
}
// Состояние мира (для памяти ИИ о локациях персонажей)
export interface WorldState {
// Текущая сцена игрока
currentScene: {
location: string; // Текущая локация ГГ
presentCharacters: string[]; // Кто присутствует в сцене
situation: string; // Краткое описание ситуации
};
// Состояние других персонажей (не в сцене)
characters: Record<string, CharacterState>;
// Последнее обновление
lastUpdated: number; // Номер сообщения
}
export interface ChatMessage { export interface ChatMessage {
id: string; id: string;
role: "user" | "assistant" | "system"; role: "user" | "assistant" | "system";
@@ -90,6 +112,8 @@ export interface GameSession {
inventory: string[]; inventory: string[];
questProgress: Record<string, boolean>; questProgress: Record<string, boolean>;
}; };
// Состояние мира (для памяти о локациях персонажей)
worldState?: WorldState;
// Сводка важных событий для контекста AI // Сводка важных событий для контекста AI
storySummary?: string; storySummary?: string;
// Ключевые события (для краткой памяти) // Ключевые события (для краткой памяти)