diff --git a/src/services/deepseek.ts b/src/services/deepseek.ts
index dd32843..e7c3e19 100644
--- a/src/services/deepseek.ts
+++ b/src/services/deepseek.ts
@@ -11,9 +11,9 @@ import type {
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 RECENT_MESSAGES_COUNT = 20; // Last N messages for context (increased for better continuity)
const SUMMARY_THRESHOLD = 20; // After how many messages to generate summary
-const WORLD_STATE_UPDATE_INTERVAL = 5; // Update world state every N messages
+const WORLD_STATE_UPDATE_INTERVAL = 3; // Update world state every N messages (reduced for better continuity)
interface DeepSeekMessage {
role: "system" | "user" | "assistant";
@@ -194,9 +194,32 @@ This is an adult story. Restrictions lifted for:
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
+3. Do not censor what happens
+4. Maintain the atmosphere and tone of the story
+
+CONTENT INITIATION RULES:
+- Violence, blood, death, dark themes — AI can and should initiate these naturally
+ when the story calls for it. These are part of the narrative, not player-triggered.
+
+INITIATING INTIMATE SCENES:
+- When the story context clearly leads to intimacy (flirtation established,
+ characters alone, player makes a direct move or proposition) — transition
+ into the scene naturally. Do NOT ask for confirmation. Do NOT stall with
+ questions. The context is the consent.
+- "Are you sure?", "What do you want to do?", "She waits for your answer"
+ during an obvious intimate moment — FORBIDDEN.
+
+CHARACTER INTEGRITY DURING INTIMATE SCENES:
+- A character's personality defines HOW they engage in intimacy, not WHETHER.
+- Cold and dominant characters (like Азул) do not become submissive or
+ confessional. They take control. They set the terms. They act first.
+- Vulnerable admissions and character breaks require sessions of development —
+ not one exchange. A queen who "rules through inevitability" doesn't say
+ "я твоя" after one conversation.
+- Show the character's nature THROUGH the intimate scene:
+ Азул doesn't surrender — she decides. She moves first. She sets the pace.
+ Her coldness becomes precision. Her control becomes dominance.
+ That IS the scene. Not her breaking down.
`
: "";
@@ -248,6 +271,8 @@ ${nsfwBlock}
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
+9. NEVER change location without explicit player action — if player is in a room, stay in that room
+10. Keep track of where each character is — characters cannot teleport between scenes
=== PROTAGONIST HANDLING ===
Guidelines for the main character (MC):
@@ -293,8 +318,26 @@ Description: ${playerDescription}`;
export function buildWorldContext(story: Story): string {
const charactersInfo =
story.characters.length > 0
- ? story.characters
- .map((c) => `- ${c.name} (${c.role}): ${c.description}`)
+ ? `CHARACTER PORTRAYAL IS LAW — not suggestion. Every action, reaction, ` +
+ `word and silence must match the description exactly. ` +
+ `A character's description overrides all genre tropes and archetypes. ` +
+ `If described as cold — they NEVER monologue about hero's boldness. ` +
+ `If described as ruthless — they don't pause dramatically to evaluate him. ` +
+ `Portray exactly what is written, never what is "typical" for this archetype.\n\n` +
+ `FORBIDDEN CLICHÉS (NEVER USE):\n` +
+ `- "Ты либо очень смелый, либо очень глупый" / "You're either very brave or very foolish"\n` +
+ `- "Интересно..." / "Interesting..." as a reaction to boldness\n` +
+ `- "Ты дерзкий, мне это нравится" / "You're bold, I like that"\n` +
+ `- "Никто никогда не осмеливался..." / "No one has ever dared..."\n` +
+ `- "Вопрос только — как именно я его запомню" / "The only question is how I'll remember him"\n` +
+ `- Dramatic pauses to evaluate the hero's courage\n` +
+ `- Surprised reactions to player's "bravery" or "audacity"\n` +
+ `These are lazy AI patterns. Characters simply ACT according to their nature.\n\n` +
+ story.characters
+ .map(
+ (c) =>
+ `- ${c.name} (${c.role}, PORTRAY EXACTLY AS DESCRIBED): ${c.description}`,
+ )
.join("\n")
: "Not specified";
@@ -305,6 +348,10 @@ Description: ${story.world.description}
World rules: ${story.world.rules.join("; ")}
=== WORLD CHARACTERS ===
+Character roles indicate their NARRATIVE function, not relationship status:
+- "Романс" / "Romance" = potential love interest, NOT already in relationship
+- "Антагонист" / "Antagonist" = story adversary, not necessarily evil
+- Relationships must develop through actual story events
${charactersInfo}
=== MAIN PLOT ===
@@ -321,7 +368,7 @@ export function buildDynamicContext(
const state = session.currentState;
const summary = session.storySummary || "The story just began.";
const keyEvents = session.keyEvents?.length
- ? session.keyEvents.slice(-5).join("\n- ")
+ ? session.keyEvents.slice(-7).join("\n- ")
: "No significant events yet.";
// World state for character tracking
@@ -339,7 +386,8 @@ export function buildDynamicContext(
• Format dialogue: **"text"** (double asterisks = bold)
• React to player's words explicitly
• CRITICAL: Write every word COMPLETELY. No truncated words. Perfect grammar required!
-• Characters can only be where they logically should be based on their last known location`
+• Characters can only be where they logically should be based on their last known location
+• RESPECT THE SUMMARY BELOW - it contains crucial story info you may not see in recent messages`
: "";
return `
@@ -348,7 +396,10 @@ Health: ${state.health}%
Inventory: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Empty"}
${worldStateContext}
-=== STORY SUMMARY ===
+=== STORY SUMMARY (IMPORTANT - THIS IS YOUR MEMORY) ===
+The following summary contains CRITICAL information about past events, characters, promises, and relationships.
+You MUST use this info to maintain story consistency:
+
${summary}
=== KEY EVENTS ===
@@ -392,13 +443,19 @@ export async function generateStoryResponse(
// Build final system prompt
const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext;
+ // Build scene anchor from world state (last thing model sees before generating)
+ const sceneAnchor = buildSceneAnchor(session?.worldState);
+ const finalUserMessage = sceneAnchor
+ ? `${userMessage}\n\n${sceneAnchor}`
+ : userMessage;
+
const messages: DeepSeekMessage[] = [
{ role: "system", content: systemPrompt },
...recentMessages.map((msg) => ({
role: msg.role as "user" | "assistant",
content: msg.content,
})),
- { role: "user", content: userMessage },
+ { role: "user", content: finalUserMessage },
];
// Use temperature from story settings (default 0.9 for balanced creative writing)
@@ -425,13 +482,67 @@ export async function generateStoryResponseStream(
const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT);
const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext;
+ // Handle [CONTINUE] - AI should continue from last message
+ const isContinuation = userMessage === "[CONTINUE]";
+
+ // Build scene anchor from world state (last thing model sees before generating)
+ const sceneAnchor = buildSceneAnchor(session?.worldState);
+
+ let finalUserMessage: string;
+ if (isContinuation) {
+ // For continuation, get the last assistant message content to continue from
+ const lastAssistantContent =
+ session?.messages
+ ?.slice()
+ .reverse()
+ .find((m) => m.role === "assistant")?.content || "";
+
+ // Extract last ~500 chars for context - get the ending
+ const continuationContext = lastAssistantContent.slice(-500);
+
+ // Get the very last sentence/phrase to make it crystal clear
+ const lastSentence =
+ lastAssistantContent
+ .split(/[.!?»"]\s*/)
+ .filter((s) => s.trim())
+ .pop() || "";
+
+ finalUserMessage = `[SYSTEM INSTRUCTION - CONTINUATION MODE]
+You MUST continue the narrative EXACTLY from where it stopped.
+
+CRITICAL RULES:
+1. DO NOT repeat ANY part of what was already written
+2. DO NOT start with the same words or phrases
+3. Pick up MID-THOUGHT or MID-ACTION if that's where it stopped
+4. Write NEW content only - as if you're typing the next paragraph
+
+The story currently ends with:
+"...${continuationContext}"
+
+The very last phrase was: "${lastSentence}..."
+
+NOW CONTINUE FROM HERE - write what happens NEXT, not what already happened.
+${sceneAnchor ? `\n${sceneAnchor}` : ""}`;
+ } else {
+ finalUserMessage = sceneAnchor
+ ? `${userMessage}\n\n${sceneAnchor}`
+ : userMessage;
+ }
+
+ // Truncate very long messages to avoid API limits (50k chars max per message)
+ const MAX_MESSAGE_LENGTH = 40000;
+ const truncatedMessages = recentMessages.map((msg) => ({
+ role: msg.role as "user" | "assistant",
+ content:
+ msg.content.length > MAX_MESSAGE_LENGTH
+ ? msg.content.slice(-MAX_MESSAGE_LENGTH) // Keep the END (most recent part)
+ : msg.content,
+ }));
+
const messages: DeepSeekMessage[] = [
{ role: "system", content: systemPrompt },
- ...recentMessages.map((msg) => ({
- role: msg.role as "user" | "assistant",
- content: msg.content,
- })),
- { role: "user", content: userMessage },
+ ...truncatedMessages,
+ { role: "user", content: finalUserMessage },
];
return sendMessageStream(messages, story.temperature || 0.9, onChunk, signal);
@@ -469,6 +580,18 @@ export async function generateStorySummary(
- What character ALREADY said or did (brief):
- What character KNOWS and DOESN'T KNOW (brief):`;
+ const importantReminder = `
+CRITICAL - PRESERVE THESE DETAILS:
+- All promises, deals, agreements made by ANY character
+- Character deaths, injuries, or status changes
+- Romantic developments (confessions, kisses, relationship progress)
+- Items given, received, or lost
+- Locations visited and current location
+- Names of ALL characters who appeared
+- Any secrets revealed or discovered
+- Character motivations and goals
+- Unresolved conflicts or tensions`;
+
const prompt = previousSummary
? `Update the story summary with new events.
@@ -477,26 +600,38 @@ ${previousSummary}
NEW EVENTS:
${conversationText}
+${importantReminder}
Write an updated summary in the following format:
=== GENERAL SUMMARY ===
-Briefly (3-4 sentences): key events, hero's location, important decisions.
+(5-7 sentences): key events, hero's current location, important decisions, ongoing conflicts.
+
+=== IMPORTANT FACTS ===
+- List any promises/agreements made
+- List any items gained/lost
+- List current goals/quests
=== CHARACTER CARDS ===
For EACH character that appeared in the story, fill out a card:
${characterTemplate}
-Update character info based on new events. Maintain consistency.
+NEVER remove character info from previous summary. Only add new info or update existing.
Write the summary in language: ${story.language}`
: `Create a summary of this story's events:
${conversationText}
+${importantReminder}
Write the summary in the following format:
=== GENERAL SUMMARY ===
-Briefly (3-4 sentences): what happened, hero's location, important decisions.
+(5-7 sentences): what happened, hero's location, important decisions, ongoing conflicts.
+
+=== IMPORTANT FACTS ===
+- List any promises/agreements made
+- List any items gained/lost
+- List current goals/quests
=== CHARACTER CARDS ===
For EACH character that appeared in the story, fill out a card:
@@ -508,12 +643,22 @@ 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}`,
+ content: `You are a story summary assistant. Your summaries are CRITICAL for story continuity - the main AI RELIES on them to remember what happened.
+
+PRIORITIES:
+1. Character relationships and romantic developments
+2. Promises, deals, agreements - these MUST be preserved
+3. Character deaths or major status changes
+4. Items, abilities, resources
+5. Current location and situation
+
+Never omit important details. If previous summary had info about a character, KEEP that info and add new details.
+Output in language: ${story.language}`,
},
{ role: "user", content: prompt },
];
- return sendMessage(summaryMessages, 0.3);
+ return sendMessage(summaryMessages, 0.1); // Low temp for factual, concise summary
}
/**
@@ -732,3 +877,20 @@ Situation: ${currentScene.situation}`;
return result;
}
+
+/**
+ * Builds a brief scene anchor to append to user message
+ * This is the LAST thing the model sees before generating, preventing location drift
+ */
+export function buildSceneAnchor(worldState?: WorldState): string {
+ if (!worldState?.currentScene) return "";
+
+ const { location, presentCharacters, situation } = worldState.currentScene;
+
+ const present =
+ presentCharacters.length > 0
+ ? presentCharacters.join(", ")
+ : "no one nearby";
+
+ return `[SCENE: ${location} | Present: ${present} | ${situation}. DO NOT change location without player movement action.]`;
+}
diff --git a/src/services/imageGen.ts b/src/services/imageGen.ts
index fa45e80..f5b9242 100644
--- a/src/services/imageGen.ts
+++ b/src/services/imageGen.ts
@@ -55,11 +55,36 @@ async function translateToEnglish(text: string): Promise {
}
/**
- * Generates an image from a prompt (portrait format)
+ * Extracts UUID from GeminiGen image URL
+ * URL format: https://api.geminigen.ai/storage/.png or similar
+ */
+function extractUuidFromUrl(url: string): string | null {
+ if (!url) return null;
+
+ // Try to extract UUID pattern from URL
+ const uuidPattern =
+ /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
+ const match = url.match(uuidPattern);
+ return match ? match[1] : null;
+}
+
+/**
+ * Generates an image from a prompt (portrait format by default)
* Returns image URL
*/
-async function generateImageFromPrompt(prompt: string): Promise {
- console.log("Generating image with prompt:", prompt);
+async function generateImageFromPrompt(
+ prompt: string,
+ orientation: "portrait" | "landscape" = "portrait",
+ refHistory?: string,
+): Promise {
+ console.log(
+ "Generating image with prompt:",
+ prompt,
+ "orientation:",
+ orientation,
+ "refHistory:",
+ refHistory,
+ );
const response = await fetch(`${API_BASE}/api/generate-image`, {
method: "POST",
@@ -67,7 +92,7 @@ async function generateImageFromPrompt(prompt: string): Promise {
"Content-Type": "application/json",
},
credentials: "include",
- body: JSON.stringify({ prompt }),
+ body: JSON.stringify({ prompt, orientation, refHistory }),
});
if (!response.ok) {
@@ -132,6 +157,104 @@ export async function generateAvatarUrl(
return generateImageFromPrompt(prompt);
}
+interface BannerCharacter {
+ name: string;
+ avatarUrl?: string;
+}
+
+interface GenerateBannerOptions {
+ title: string;
+ description: string;
+ genre?: string[];
+ setting?: string[];
+ isNsfw?: boolean;
+ customPrompt?: string;
+ characters?: BannerCharacter[]; // Персонажи истории для референса
+}
+
+/**
+ * Finds a character mentioned in the story description/title that has an avatar
+ * Returns the character's avatar UUID if found
+ */
+function findCharacterReference(
+ title: string,
+ description: string,
+ characters: BannerCharacter[],
+): string | null {
+ if (!characters || characters.length === 0) return null;
+
+ const searchText = `${title} ${description}`.toLowerCase();
+
+ for (const char of characters) {
+ if (!char.name || !char.avatarUrl) continue;
+
+ // Check if character name is mentioned in title/description
+ const nameLower = char.name.toLowerCase();
+ if (searchText.includes(nameLower)) {
+ const uuid = extractUuidFromUrl(char.avatarUrl);
+ if (uuid) {
+ console.log(`Found character reference: ${char.name} -> ${uuid}`);
+ return uuid;
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Generates a banner/cover image for a story (landscape 16:9 format)
+ * If a character from the story is mentioned and has an avatar, uses it as reference
+ * Otherwise generates a landscape/environment scene without characters
+ * Returns image URL
+ */
+export async function generateBannerUrl(
+ options: GenerateBannerOptions,
+): Promise {
+ const {
+ title,
+ description,
+ genre = [],
+ setting = [],
+ isNsfw,
+ customPrompt,
+ characters = [],
+ } = options;
+
+ // If custom prompt provided - use it directly
+ if (customPrompt && customPrompt.trim()) {
+ return generateImageFromPrompt(customPrompt.trim(), "landscape");
+ }
+
+ // Check if any character with avatar is mentioned in description
+ const charRefUuid = findCharacterReference(title, description, characters);
+
+ // Build automatic prompt from story info
+ const genreText = genre.length > 0 ? genre.slice(0, 3).join(", ") : "";
+ const settingText = setting.length > 0 ? setting.slice(0, 2).join(", ") : "";
+
+ // Get first 150 chars of description for context
+ const shortDesc = description
+ .slice(0, 150)
+ .replace(/\{user\}/gi, "hero")
+ .trim();
+
+ const textToTranslate = `${title} - ${shortDesc}${settingText ? `, setting: ${settingText}` : ""}`;
+ const englishDesc = await translateToEnglish(textToTranslate);
+
+ const nsfwTag = isNsfw ? "nsfw, mature, " : "";
+ const genreStyle = genreText ? `${genreText} style, ` : "";
+
+ // If no character reference - generate landscape without people
+ const noCharactersTag = charRefUuid
+ ? ""
+ : "landscape scene, environment only, no people, no characters, ";
+
+ const prompt = `${nsfwTag}anime illustration, cinematic wide shot, ${noCharactersTag}${genreStyle}epic scene depicting: ${englishDesc}, dramatic lighting, atmospheric, detailed background, masterpiece, best quality, highly detailed, vibrant colors, 16:9 banner composition`;
+
+ return generateImageFromPrompt(prompt, "landscape", charRefUuid || undefined);
+}
+
/**
* Poll for image generation result
*/