// DeepSeek API service for story generation (via backend proxy) import type { Story, ChatMessage, PlayerCharacter, GameSession, WorldState, } from "../types"; const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001"; // Context settings const RECENT_MESSAGES_COUNT = 15; // Last N messages for context (increased for better scene continuity) 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 { role: "system" | "user" | "assistant"; content: string; } interface DeepSeekResponse { choices: { message: { content: string; }; }[]; usage?: { prompt_tokens: number; completion_tokens: number; prompt_cache_hit_tokens?: number; prompt_cache_miss_tokens?: number; }; } export async function sendMessage( messages: DeepSeekMessage[], temperature: number = 0.8, max_tokens: number = 1000, ): Promise { const response = await fetch(`${API_BASE}/api/deepseek/chat`, { method: "POST", headers: { "Content-Type": "application/json", }, credentials: "include", body: JSON.stringify({ messages, temperature, max_tokens, }), }); if (!response.ok) { 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(); return data.choices[0]?.message?.content || ""; } /** * Streaming version of sendMessage - returns text in chunks */ export async function sendMessageStream( messages: DeepSeekMessage[], temperature: number = 0.8, onChunk: (chunk: string) => void, signal?: AbortSignal, ): Promise { const response = await fetch(`${API_BASE}/api/deepseek/chat/stream`, { method: "POST", headers: { "Content-Type": "application/json", }, credentials: "include", body: JSON.stringify({ messages, temperature, max_tokens: 1000, }), signal, }); if (!response.ok) { 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(); if (!reader) throw new Error("No response body"); const decoder = new TextDecoder(); let fullContent = ""; while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split("\n").filter((line) => line.trim() !== ""); for (const line of lines) { if (line.startsWith("data: ")) { const data = line.slice(6); if (data === "[DONE]") continue; try { const parsed = JSON.parse(data); const content = parsed.choices?.[0]?.delta?.content || ""; if (content) { fullContent += content; onChunk(content); } } catch { // Ignore parse errors } } } } return fullContent; } /** * Builds base system prompt (style rules) - CACHED */ export function buildStyleRules( story: Story, player?: PlayerCharacter, ): string { const settingInfo = Array.isArray(story.setting) ? story.setting.join(", ") : story.setting; const playerDescription = player?.description ? player.description.replace(/\{user\}/gi, player.name) : "Not specified"; // NSFW block const nsfwBlock = story.isNsfw ? ` === NSFW MODE ACTIVE === This is an adult story. Restrictions lifted for: - Physical violence, blood, injuries, and death scenes - Sexual content and intimate scenes - Dark themes: torture, cruelty, moral ambiguity - Explicit descriptions of bodies, wounds, pain NSFW RULES: 1. Describe scenes in detail, but artistically 2. Violence must have consequences and weight 3. Intimate scenes only on player's initiative 4. Do not censor what happens 5. Maintain the atmosphere and tone of the story ` : ""; // OOC rules (same for all stories) const oocRules = ` === OOC (OUT OF CHARACTER) MODE === When player's message starts with [OOC: ...], they are speaking DIRECTLY to you as the AI. In OOC mode: — Break character completely — Respond as a helpful AI assistant, not as the narrator — Answer questions about the story, characters, mechanics — Accept instructions to change story direction, tone, pacing — Can discuss plot ideas, suggest alternatives, clarify rules — After OOC response, do NOT continue the narrative until player sends a normal message `; // If there are custom rules - use them if (story.narrativeRules && story.narrativeRules.trim()) { return `${story.narrativeRules} ${nsfwBlock} ${oocRules} === METADATA === LANGUAGE: ${story.language} GENRE: ${story.genre.join(", ")} SETTING: ${settingInfo} IMPORTANT: Respond ONLY in language: ${story.language}. Use proper grammar and spelling. === PLAYER CHARACTER === Name: ${player?.name || "Hero"} Description: ${playerDescription}`; } // Default rules for stories without custom settings return `You are StorytellerGPT, running an interactive story. === METADATA === LANGUAGE: ${story.language} GENRE: ${story.genre.join(", ")} SETTING: ${settingInfo} ${nsfwBlock} === NARRATIVE RULES === 1. Player writes their own actions and dialogue 2. Weave player actions into the scene, describe character reactions and consequences 3. Do not make decisions for the player — let them choose 4. Do not ask questions like "What do you do?" or "What will you choose?" 5. Do not offer explicit action choices 6. Do not assume what player wants to do next 7. No time skips without explicit indication 8. If action not written by player — it did NOT happen === PROTAGONIST HANDLING === Guidelines for the main character (MC): — Do not describe MC's physical actions (walking, grabbing, looking) unless player wrote them — Do not put thoughts or internal monologue in MC's mind — Do not speak for MC unless player wrote dialogue — When player hasn't acted, describe the scene and other characters' reactions — You may describe what MC perceives (sights, sounds, sensations) to set atmosphere — End scenes with situation that invites action, not with direct questions WRONG: "Ты протягиваешь руку и берёшь кристалл. Что ты делаешь?" RIGHT: "Кристалл лежит на земле, мерцая голубым светом. Где-то вдали слышен стук копыт." === REACTIONS TO MC's DIALOGUE === Characters MUST explicitly react to MC's words: — answer asked questions — change tone, behavior, or atmosphere — show pauses, tension, confusion Ignoring MC's dialogue is FORBIDDEN. === DIALOGUE FORMAT === Format all character speech with DOUBLE asterisks (two on each side): **"text"** Single asterisks (*text*) create italics — DO NOT use for dialogue. Double asterisks (**text**) create bold — USE THIS for dialogue. WRONG: *"Я ему не доверяю,"* пробормотала она. WRONG: "Я ему не доверяю," пробормотала она. RIGHT: **"Я ему не доверяю,"** пробормотала она. Descriptions and narration — plain text without any asterisks. ${oocRules} Respond in language: ${story.language} === PLAYER CHARACTER === Name: ${player?.name || "Hero"} Description: ${playerDescription}`; } /** * Builds world context (lore) - CACHED */ export function buildWorldContext(story: Story): string { const charactersInfo = story.characters.length > 0 ? story.characters .map((c) => `- ${c.name} (${c.role}): ${c.description}`) .join("\n") : "Not specified"; return ` === WORLD === Name: ${story.world.name} Description: ${story.world.description} World rules: ${story.world.rules.join("; ")} === WORLD CHARACTERS === ${charactersInfo} === MAIN PLOT === ${story.plot}`; } /** * Builds dynamic context (state + summary + world state + rule reminders) */ 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."; // World state for character tracking const worldStateContext = formatWorldStateForContext(session.worldState); // Add rule reminders after 10+ messages to prevent drift const ruleReminder = messageCount && messageCount >= 10 ? ` === 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 ask "What do you do?" — end with atmosphere, not questions • Format dialogue: **"text"** (double asterisks = bold) • 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 ` === PLAYER STATUS === Health: ${state.health}% Inventory: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Empty"} ${worldStateContext} === STORY SUMMARY === ${summary} === KEY EVENTS === - ${keyEvents}${ruleReminder}`; } /** * Full system prompt (for backwards compatibility) */ export function buildSystemPrompt( story: Story, player?: PlayerCharacter, ): string { return buildStyleRules(story, player) + "\n" + buildWorldContext(story); } /** * Generates response with optimized context */ export async function generateStoryResponse( story: Story, chatHistory: ChatMessage[], userMessage: string, player?: PlayerCharacter, session?: GameSession, ): Promise { // 1. Style rules (cached by DeepSeek) const styleRules = buildStyleRules(story, player); // 2. World context (cached by DeepSeek) const worldContext = buildWorldContext(story); // 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); // Build final system prompt const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext; const messages: DeepSeekMessage[] = [ { role: "system", content: systemPrompt }, ...recentMessages.map((msg) => ({ role: msg.role as "user" | "assistant", content: msg.content, })), { role: "user", content: userMessage }, ]; // Use temperature from story settings (default 0.9 for balanced creative writing) return sendMessage(messages, story.temperature || 0.9); } /** * Streaming version of generateStoryResponse */ export async function generateStoryResponseStream( story: Story, chatHistory: ChatMessage[], userMessage: string, onChunk: (chunk: string) => void, player?: PlayerCharacter, session?: GameSession, signal?: AbortSignal, ): Promise { const styleRules = buildStyleRules(story, player); const worldContext = buildWorldContext(story); const dynamicContext = session ? buildDynamicContext(session, chatHistory.length) : ""; const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT); const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext; const messages: DeepSeekMessage[] = [ { role: "system", content: systemPrompt }, ...recentMessages.map((msg) => ({ role: msg.role as "user" | "assistant", content: msg.content, })), { role: "user", content: userMessage }, ]; return sendMessageStream(messages, story.temperature || 0.9, onChunk, signal); } /** * Generates story summary (call periodically) */ export async function generateStorySummary( story: Story, messages: ChatMessage[], previousSummary?: string, ): Promise { // Get messages for summarization (excluding recent ones, they're fresh) const messagesToSummarize = messages.slice(0, -RECENT_MESSAGES_COUNT); if (messagesToSummarize.length < SUMMARY_THRESHOLD) { return previousSummary || ""; } const conversationText = messagesToSummarize .map((m) => `${m.role === "user" ? "Player" : "Narrator"}: ${m.content}`) .join("\n\n"); const characterTemplate = ` === [CHARACTER NAME] === - Appearance (brief): - Personality and speech style: - Promises made (brief): - Current location: - Alive (yes/no/unknown): - How they address MC (formal/informal): - Attitude towards MC: - Romantic relationship with MC: - What character ALREADY said or did (brief): - What character KNOWS and DOESN'T KNOW (brief):`; const prompt = previousSummary ? `Update the story summary with new events. PREVIOUS SUMMARY: ${previousSummary} NEW EVENTS: ${conversationText} Write an updated summary in the following format: === GENERAL SUMMARY === Briefly (3-4 sentences): key events, hero's location, important decisions. === CHARACTER CARDS === For EACH character that appeared in the story, fill out a card: ${characterTemplate} Update character info based on new events. Maintain consistency. Write the summary in language: ${story.language}` : `Create a summary of this story's events: ${conversationText} Write the summary in the following format: === GENERAL SUMMARY === Briefly (3-4 sentences): what happened, hero's location, important decisions. === CHARACTER CARDS === For EACH character that appeared in the story, fill out a card: ${characterTemplate} If info is missing — write "unknown" or skip the field. Write the summary in language: ${story.language}`; const summaryMessages: DeepSeekMessage[] = [ { role: "system", content: `You are a story summary assistant. Write concisely and to the point. Pay special attention to romantic storylines and character relationships — this is important for story consistency. Fill character cards only based on what actually happened in the story. Output in language: ${story.language}`, }, { role: "user", content: prompt }, ]; return sendMessage(summaryMessages, 0.3); } /** * Extracts key events from AI response */ export async function extractKeyEvents( aiResponse: string, existingEvents: string[] = [], ): Promise { // Simple heuristics: look for important actions const importantPatterns = [ /(?:ты |you )(?:получил|found|defeated|killed|saved|met|learned|discovered|reached)/gi, /(?:new |новый |новая |новое )(?:quest|task|ability|item|ally|квест|задание|способность|предмет|союзник)/gi, /(?:died|perished|lost|betrayed|умер|погиб|потерял|предал)/gi, // Romantic events /(?:kiss|embrace|confess|love|flirt|date|romantic|поцелу|обнял|признал|влюбил|призналась|призналось|флирт|свидание|романтич)/gi, /(?:held|took|squeezed|held hands|держ|взял|сжал).*(?:hand|palm|руку|ладонь|за руку)/gi, ]; const newEvents: string[] = []; for (const pattern of importantPatterns) { const matches = aiResponse.match(pattern); if (matches) { // Extract sentence with the event const sentences = aiResponse.split(/[.!?]/); for (const sentence of sentences) { if ( pattern.test(sentence) && sentence.length > 20 && sentence.length < 150 ) { newEvents.push(sentence.trim()); break; } } } } // Combine with existing, limit to 10 const allEvents = [...existingEvents, ...newEvents]; return allEvents.slice(-10); } export async function generateStoryDescription( title: string, genre: string[], setting: string[], worldDescription: string, ): Promise { const messages: DeepSeekMessage[] = [ { role: "system", content: "You are an isekai story writer. Create brief, captivating descriptions for stories.", }, { role: "user", content: `Create a brief description (2-3 sentences) for an isekai story: Title: ${title} Genre: ${genre.join(", ")} Setting: ${setting.join(", ")} World: ${worldDescription} The description should be intriguing and make the reader want to start the adventure. Write in Russian.`, }, ]; 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 { 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": , "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; }