Files
ReSekai/src/services/deepseek.ts
T

535 lines
16 KiB
TypeScript

// DeepSeek API service for story generation (via backend proxy)
import type {
Story,
ChatMessage,
PlayerCharacter,
GameSession,
} from "../types";
const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001";
// Context settings
const RECENT_MESSAGES_COUNT = 6; // Last N messages for context
const SUMMARY_THRESHOLD = 15; // After how many messages to generate summary
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<string> {
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) {
throw new Error(`DeepSeek API error: ${response.status}`);
}
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<string> {
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) {
throw new Error(`DeepSeek API error: ${response.status}`);
}
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}
=== 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 + rule reminders)
*/
export function buildDynamicContext(
session: GameSession,
messageCount?: number,
hasCustomRules?: boolean,
): 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.";
// Add rule reminders after 10+ messages to prevent drift
// Skip if story has custom rules - they take priority
const ruleReminder =
messageCount && messageCount >= 10 && !hasCustomRules
? `
=== 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 `
=== CURRENT STATE ===
Location: ${state.location}
Health: ${state.health}%
Inventory: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Empty"}
=== 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<string> {
// 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 hasCustomRules = Boolean(story.narrativeRules?.trim());
const dynamicContext = session
? buildDynamicContext(session, chatHistory.length, hasCustomRules)
: "";
// 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<string> {
const styleRules = buildStyleRules(story, player);
const worldContext = buildWorldContext(story);
const hasCustomRules = Boolean(story.narrativeRules?.trim());
const dynamicContext = session
? buildDynamicContext(session, chatHistory.length, hasCustomRules)
: "";
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<string> {
// 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<string[]> {
// 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<string> {
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);
}