535 lines
16 KiB
TypeScript
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);
|
|
}
|