Major refactor: security, performance, and code organization

Security:
- DeepSeek API moved to server-side proxy with rate limiting (20 req/min)
- Whitelist validation for all POST/PUT routes
- Cookie security (secure, sameSite, httpOnly in production)
- Input validation for messages, tokens, temperature
- Sanitized hasOwnProperty to prevent prototype pollution

Performance:
- Lazy loading for chat messages (sliding window of 20)
- Streaming response throttling (50ms batches)
- Scroll optimization (only scroll on new messages)
- AbortController fix for stop button

Code organization:
- GamePage refactored from ~1170 to ~750 lines
- New hooks: useGameSession, useStreamingResponse, useCharacterDetection, useLazyMessages
- New components: MessageList, ChatInput, SessionSelector, CharacterPanel
- Fixed ESLint errors

Features:
- OOC mode button for direct AI instructions
- Message versions (aiResponse) now persist to DB
- playerId saved in sessions
This commit is contained in:
Alexej Wolff
2026-05-05 23:41:52 +02:00
parent bbefa114f8
commit 68c2b129fa
23 changed files with 1817 additions and 553 deletions
-3
View File
@@ -1,5 +1,2 @@
# DeepSeek API ключ для генерации историй
VITE_DEEPSEEK_API_KEY=your_api_key_here
# URL бэкенд сервера
VITE_API_URL=http://localhost:3001
+2 -2
View File
@@ -37,8 +37,8 @@ cd resekai
npm install
cd server && npm install && cd ..
# Создать .env файл
echo "VITE_DEEPSEEK_API_KEY=your_api_key" > .env
# Настроить backend (скопировать .env.example и заполнить)
# Обязательно указать DEEPSEEK_API_KEY в server/.env
# Запустить в режиме разработки
npm run dev # Frontend (порт 5173)
+7
View File
@@ -14,3 +14,10 @@ FRONTEND_URL=http://localhost:5174
# Server
PORT=3001
NODE_ENV=development # 'production' for secure cookies and strict checks
# DeepSeek API (for story generation)
DEEPSEEK_API_KEY=your_deepseek_api_key
# GeminiGen API (for image generation)
GEMINIGEN_API_KEY=your_geminigen_api_key
+484 -17
View File
@@ -7,6 +7,30 @@ import dotenv from "dotenv";
dotenv.config();
// Проверка обязательных переменных окружения
const isProduction = process.env.NODE_ENV === "production";
const requiredEnvVars = [
"MONGODB_URI",
"SESSION_SECRET",
"DISCORD_CLIENT_ID",
"DISCORD_CLIENT_SECRET",
"DISCORD_REDIRECT_URI",
"FRONTEND_URL",
];
const missingVars = requiredEnvVars.filter((v) => !process.env[v]);
if (missingVars.length > 0) {
console.error(
"❌ Missing required environment variables:",
missingVars.join(", "),
);
if (isProduction) {
process.exit(1);
} else {
console.warn("⚠️ Running in development mode with missing vars");
}
}
const app = express();
const PORT = process.env.PORT || 3001;
@@ -21,7 +45,7 @@ async function connectDB() {
}
// Middleware
app.use(express.json());
app.use(express.json({ limit: "10mb" }));
app.use(
cors({
origin: process.env.FRONTEND_URL,
@@ -40,8 +64,9 @@ app.use(
collectionName: "sessions",
}),
cookie: {
secure: false, // true в production с HTTPS
secure: isProduction, // true в production с HTTPS
httpOnly: true,
sameSite: isProduction ? "strict" : "lax",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
},
}),
@@ -55,6 +80,192 @@ const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI;
// ============ AUTH ROUTES ============
// Whitelist полей для безопасного обновления
const ALLOWED_STORY_FIELDS = [
"title",
"description",
"genre",
"setting",
"world",
"characters",
"plot",
"firstMessage",
"language",
"isNsfw",
"temperature",
"narrativeRules",
"protagonist",
"coverImage",
];
const ALLOWED_SESSION_FIELDS = [
"name",
"messages",
"currentState",
"storySummary",
"keyEvents",
"playerId",
];
const ALLOWED_CHARACTER_FIELDS = ["name", "description", "age", "avatarUrl"];
const ALLOWED_NPC_FIELDS = [
"name",
"description",
"role",
"age",
"gender",
"avatarUrl",
"isNsfw",
];
// Функция для фильтрации полей
function pickAllowedFields(obj, allowedFields) {
// Guard: ensure obj is a plain object
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
return {};
}
const result = {};
for (const field of allowedFields) {
if (Object.prototype.hasOwnProperty.call(obj, field)) {
result[field] = obj[field];
}
}
return result;
}
// ============ DEEPSEEK RATE LIMITING & VALIDATION ============
const DEEPSEEK_LIMITS = {
MAX_TOKENS_LIMIT: 4096,
MAX_MESSAGES: 100,
MAX_MESSAGE_LENGTH: 32000, // ~8k tokens per message
MAX_TOTAL_LENGTH: 128000, // ~32k tokens total
RATE_LIMIT_WINDOW_MS: 60 * 1000, // 1 minute
RATE_LIMIT_MAX_REQUESTS: 20, // 20 requests per minute per user
};
// Simple in-memory rate limiter (per userId)
const rateLimitStore = new Map();
function checkRateLimit(userId) {
const now = Date.now();
const windowStart = now - DEEPSEEK_LIMITS.RATE_LIMIT_WINDOW_MS;
let userRequests = rateLimitStore.get(userId) || [];
// Remove old requests outside window
userRequests = userRequests.filter((ts) => ts > windowStart);
if (userRequests.length >= DEEPSEEK_LIMITS.RATE_LIMIT_MAX_REQUESTS) {
return {
allowed: false,
remaining: 0,
resetIn: Math.ceil(
(userRequests[0] + DEEPSEEK_LIMITS.RATE_LIMIT_WINDOW_MS - now) / 1000,
),
};
}
userRequests.push(now);
rateLimitStore.set(userId, userRequests);
return {
allowed: true,
remaining: DEEPSEEK_LIMITS.RATE_LIMIT_MAX_REQUESTS - userRequests.length,
};
}
// Cleanup old rate limit entries every 5 minutes
setInterval(
() => {
const cutoff = Date.now() - DEEPSEEK_LIMITS.RATE_LIMIT_WINDOW_MS;
for (const [userId, requests] of rateLimitStore.entries()) {
const filtered = requests.filter((ts) => ts > cutoff);
if (filtered.length === 0) {
rateLimitStore.delete(userId);
} else {
rateLimitStore.set(userId, filtered);
}
}
},
5 * 60 * 1000,
);
function validateDeepSeekRequest(body) {
const errors = [];
const { messages, temperature, max_tokens } = body;
// Validate messages
if (!Array.isArray(messages)) {
errors.push("messages must be an array");
} else {
if (messages.length === 0) {
errors.push("messages cannot be empty");
}
if (messages.length > DEEPSEEK_LIMITS.MAX_MESSAGES) {
errors.push(`too many messages (max ${DEEPSEEK_LIMITS.MAX_MESSAGES})`);
}
let totalLength = 0;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (!msg || typeof msg !== "object") {
errors.push(`messages[${i}] must be an object`);
continue;
}
if (!msg.role || !["system", "user", "assistant"].includes(msg.role)) {
errors.push(
`messages[${i}].role must be 'system', 'user', or 'assistant'`,
);
}
if (typeof msg.content !== "string") {
errors.push(`messages[${i}].content must be a string`);
} else {
if (msg.content.length > DEEPSEEK_LIMITS.MAX_MESSAGE_LENGTH) {
errors.push(
`messages[${i}].content too long (max ${DEEPSEEK_LIMITS.MAX_MESSAGE_LENGTH} chars)`,
);
}
totalLength += msg.content.length;
}
}
if (totalLength > DEEPSEEK_LIMITS.MAX_TOTAL_LENGTH) {
errors.push(
`total message content too long (max ${DEEPSEEK_LIMITS.MAX_TOTAL_LENGTH} chars)`,
);
}
}
// Validate temperature
if (temperature !== undefined) {
if (typeof temperature !== "number" || temperature < 0 || temperature > 2) {
errors.push("temperature must be a number between 0 and 2");
}
}
// Validate max_tokens
if (max_tokens !== undefined) {
if (
typeof max_tokens !== "number" ||
!Number.isInteger(max_tokens) ||
max_tokens < 1 ||
max_tokens > DEEPSEEK_LIMITS.MAX_TOKENS_LIMIT
) {
errors.push(
`max_tokens must be an integer between 1 and ${DEEPSEEK_LIMITS.MAX_TOKENS_LIMIT}`,
);
}
}
return errors;
}
function sanitizeDeepSeekMessages(messages) {
return messages.map((msg) => ({
role: msg.role,
content: String(msg.content),
}));
}
// Начало авторизации Discord
app.get("/auth/discord", (req, res) => {
const params = new URLSearchParams({
@@ -241,8 +452,9 @@ app.get("/api/stories/:id", requireAuth, async (req, res) => {
app.post("/api/stories", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
const allowedData = pickAllowedFields(req.body, ALLOWED_STORY_FIELDS);
const newStory = {
...req.body,
...allowedData,
userId: req.session.userId,
createdAt: new Date(),
updatedAt: new Date(),
@@ -260,6 +472,8 @@ app.post("/api/stories", requireAuth, async (req, res) => {
app.put("/api/stories/:id", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
const allowedData = pickAllowedFields(req.body, ALLOWED_STORY_FIELDS);
const result = await stories.updateOne(
{
_id: new ObjectId(req.params.id),
@@ -267,7 +481,7 @@ app.put("/api/stories/:id", requireAuth, async (req, res) => {
},
{
$set: {
...req.body,
...allowedData,
updatedAt: new Date(),
},
},
@@ -416,18 +630,40 @@ app.put("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => {
});
const oldMessageCount = oldSession?.messages?.length || 0;
// Преобразуем timestamp строки обратно в Date
const messages = (req.body.messages || []).map((msg) => ({
...msg,
timestamp: new Date(msg.timestamp),
}));
// Фильтруем разрешенные поля
const allowedData = pickAllowedFields(req.body, ALLOWED_SESSION_FIELDS);
// Убираем _id и createdAt из body чтобы не было конфликтов
const { createdAt, _id, id, ...bodyWithoutMeta } = req.body;
// Преобразуем timestamp строки обратно в Date
if (allowedData.messages) {
allowedData.messages = allowedData.messages.map((msg) => {
const sanitized = {
id: msg.id,
role: msg.role,
content: msg.content,
timestamp: new Date(msg.timestamp),
};
// Сохраняем версии для редактирования сообщений
if (Array.isArray(msg.versions) && msg.versions.length > 0) {
sanitized.versions = msg.versions.map((v) => {
const ver = {
content: v.content,
timestamp: new Date(v.timestamp),
};
// Сохраняем связанный ответ ИИ для переключения версий
if (typeof v.aiResponse === "string") {
ver.aiResponse = v.aiResponse;
}
return ver;
});
sanitized.activeVersion =
typeof msg.activeVersion === "number" ? msg.activeVersion : 0;
}
return sanitized;
});
}
const sessionData = {
...bodyWithoutMeta,
messages,
...allowedData,
updatedAt: new Date(),
};
@@ -445,6 +681,7 @@ app.put("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => {
}
// Логируем новые токены (только для новых сообщений)
const messages = allowedData.messages || [];
const newMessages = messages.slice(oldMessageCount);
if (newMessages.length > 0) {
const tokenUsage = db.collection("token_usage");
@@ -539,8 +776,9 @@ app.get("/api/characters/:id", requireAuth, async (req, res) => {
app.post("/api/characters", requireAuth, async (req, res) => {
try {
const characters = db.collection("player_characters");
const allowedData = pickAllowedFields(req.body, ALLOWED_CHARACTER_FIELDS);
const newCharacter = {
...req.body,
...allowedData,
userId: req.session.userId,
createdAt: new Date(),
updatedAt: new Date(),
@@ -558,6 +796,8 @@ app.post("/api/characters", requireAuth, async (req, res) => {
app.put("/api/characters/:id", requireAuth, async (req, res) => {
try {
const characters = db.collection("player_characters");
const allowedData = pickAllowedFields(req.body, ALLOWED_CHARACTER_FIELDS);
const result = await characters.updateOne(
{
_id: new ObjectId(req.params.id),
@@ -565,7 +805,7 @@ app.put("/api/characters/:id", requireAuth, async (req, res) => {
},
{
$set: {
...req.body,
...allowedData,
updatedAt: new Date(),
},
},
@@ -644,8 +884,9 @@ app.get("/api/npc/:id", requireAuth, async (req, res) => {
app.post("/api/npc", requireAuth, async (req, res) => {
try {
const npcCharacters = db.collection("npc_characters");
const allowedData = pickAllowedFields(req.body, ALLOWED_NPC_FIELDS);
const newNPC = {
...req.body,
...allowedData,
userId: req.session.userId,
createdAt: new Date(),
updatedAt: new Date(),
@@ -663,6 +904,8 @@ app.post("/api/npc", requireAuth, async (req, res) => {
app.put("/api/npc/:id", requireAuth, async (req, res) => {
try {
const npcCharacters = db.collection("npc_characters");
const allowedData = pickAllowedFields(req.body, ALLOWED_NPC_FIELDS);
const result = await npcCharacters.updateOne(
{
_id: new ObjectId(req.params.id),
@@ -670,7 +913,7 @@ app.put("/api/npc/:id", requireAuth, async (req, res) => {
},
{
$set: {
...req.body,
...allowedData,
updatedAt: new Date(),
},
},
@@ -930,9 +1173,233 @@ app.get("/api/generate-image/status/:uuid", requireAuth, async (req, res) => {
}
});
// ============ DEEPSEEK PROXY ============
const DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";
// Regular chat completion
app.post("/api/deepseek/chat", requireAuth, async (req, res) => {
try {
// Rate limiting
const rateCheck = checkRateLimit(req.session.userId);
if (!rateCheck.allowed) {
return res.status(429).json({
error: "Rate limit exceeded",
retryAfter: rateCheck.resetIn,
});
}
// Validation
const validationErrors = validateDeepSeekRequest(req.body);
if (validationErrors.length > 0) {
return res
.status(400)
.json({ error: "Validation failed", details: validationErrors });
}
const apiKey = process.env.DEEPSEEK_API_KEY;
if (!apiKey) {
return res.status(500).json({ error: "DeepSeek API key not configured" });
}
const { messages, temperature = 0.8, max_tokens = 1000 } = req.body;
const sanitizedMessages = sanitizeDeepSeekMessages(messages);
const clampedMaxTokens = Math.min(
max_tokens,
DEEPSEEK_LIMITS.MAX_TOKENS_LIMIT,
);
const response = await fetch(DEEPSEEK_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: sanitizedMessages,
temperature,
max_tokens: clampedMaxTokens,
}),
});
if (!response.ok) {
const error = await response.text();
console.error("DeepSeek API error:", response.status, error);
return res.status(response.status).json({ error: "DeepSeek API error" });
}
const data = await response.json();
res.json(data);
} catch (error) {
console.error("DeepSeek proxy error:", error);
res.status(500).json({ error: "Failed to call DeepSeek API" });
}
});
// Streaming chat completion
app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => {
try {
// Rate limiting
const rateCheck = checkRateLimit(req.session.userId);
if (!rateCheck.allowed) {
return res.status(429).json({
error: "Rate limit exceeded",
retryAfter: rateCheck.resetIn,
});
}
// Validation
const validationErrors = validateDeepSeekRequest(req.body);
if (validationErrors.length > 0) {
return res
.status(400)
.json({ error: "Validation failed", details: validationErrors });
}
const apiKey = process.env.DEEPSEEK_API_KEY;
if (!apiKey) {
return res.status(500).json({ error: "DeepSeek API key not configured" });
}
const { messages, temperature = 0.8, max_tokens = 1000 } = req.body;
const sanitizedMessages = sanitizeDeepSeekMessages(messages);
const clampedMaxTokens = Math.min(
max_tokens,
DEEPSEEK_LIMITS.MAX_TOKENS_LIMIT,
);
const response = await fetch(DEEPSEEK_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: sanitizedMessages,
temperature,
max_tokens: clampedMaxTokens,
stream: true,
}),
});
if (!response.ok) {
const error = await response.text();
console.error("DeepSeek stream error:", response.status, error);
return res.status(response.status).json({ error: "DeepSeek API error" });
}
// Set headers for SSE
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Pipe the stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
const pump = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
res.end();
break;
}
const chunk = decoder.decode(value, { stream: true });
res.write(chunk);
}
} catch (error) {
console.error("Stream pump error:", error);
res.end();
}
};
// Handle client disconnect
req.on("close", () => {
reader.cancel();
});
pump();
} catch (error) {
console.error("DeepSeek stream proxy error:", error);
res.status(500).json({ error: "Failed to call DeepSeek API" });
}
});
// Translation endpoint (for image prompts)
app.post("/api/deepseek/translate", requireAuth, async (req, res) => {
try {
// Rate limiting (shares limit with chat endpoints)
const rateCheck = checkRateLimit(req.session.userId);
if (!rateCheck.allowed) {
return res.status(429).json({
error: "Rate limit exceeded",
retryAfter: rateCheck.resetIn,
});
}
// Validate text
const { text } = req.body;
if (typeof text !== "string") {
return res.status(400).json({ error: "text must be a string" });
}
if (text.length === 0) {
return res.status(400).json({ error: "text cannot be empty" });
}
if (text.length > 2000) {
return res.status(400).json({ error: "text too long (max 2000 chars)" });
}
const apiKey = process.env.DEEPSEEK_API_KEY;
if (!apiKey) {
return res.status(500).json({ error: "DeepSeek API key not configured" });
}
const response = await fetch(DEEPSEEK_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: [
{
role: "system",
content:
"Translate to English for image generation. Output ONLY the translation, nothing else. Keep character names as-is. Be concise.",
},
{
role: "user",
content: text,
},
],
temperature: 0.1,
max_tokens: 150,
}),
});
if (!response.ok) {
const error = await response.text();
console.error("DeepSeek translate error:", response.status, error);
return res.status(response.status).json({ error: "Translation failed" });
}
const data = await response.json();
const translated = data.choices?.[0]?.message?.content?.trim() || text;
res.json({ translated });
} catch (error) {
console.error("Translation proxy error:", error);
res.status(500).json({ error: "Failed to translate" });
}
});
// Запуск сервера
connectDB().then(() => {
app.listen(PORT, () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
console.log(` Mode: ${isProduction ? "production" : "development"}`);
console.log(` Secure cookies: ${isProduction}`);
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { Character } from "../../types";
interface CharacterPanelProps {
character: Character | null;
}
export function CharacterPanel({ character }: CharacterPanelProps) {
if (!character?.avatarUrl) return null;
return (
<div className="character-panel">
<img src={character.avatarUrl} alt={character.name} />
<div className="character-panel-info">
<span className="character-panel-name">{character.name}</span>
<span className="character-panel-role">{character.role}</span>
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import React, { useRef, useEffect } from "react";
interface ChatInputProps {
value: string;
onChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
isLoading: boolean;
disabled?: boolean;
placeholder?: string;
}
export function ChatInput({
value,
onChange,
onSend,
onStop,
isLoading,
disabled = false,
placeholder = "Что ты хочешь сделать?...",
}: ChatInputProps) {
const inputRef = useRef<HTMLTextAreaElement>(null);
// Focus input after loading completes
useEffect(() => {
if (!isLoading) {
inputRef.current?.focus();
}
}, [isLoading]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSend();
}
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
// Auto-resize
e.target.style.height = "auto";
e.target.style.height = Math.min(e.target.scrollHeight, 150) + "px";
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSend();
};
// Reset textarea height when value is cleared
useEffect(() => {
if (!value && inputRef.current) {
inputRef.current.style.height = "auto";
}
}, [value]);
return (
<form className="input-container" onSubmit={handleSubmit}>
<textarea
ref={inputRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isLoading || disabled}
rows={1}
name="chat-input"
autoComplete="off"
autoCorrect="on"
autoCapitalize="sentences"
spellCheck={true}
enterKeyHint="send"
data-form-type="other"
data-lpignore="true"
data-gramm="false"
/>
{isLoading ? (
<button type="button" onClick={onStop} className="send-btn stop-btn">
</button>
) : (
<button type="submit" disabled={!value.trim()} className="send-btn">
</button>
)}
</form>
);
}
+208
View File
@@ -0,0 +1,208 @@
import React from "react";
import ReactMarkdown from "react-markdown";
import type { ChatMessage } from "../../types";
interface MessageItemProps {
message: ChatMessage;
isEditing: boolean;
editContent: string;
isLoading: boolean;
onEditContentChange: (content: string) => void;
onEditMessage: (messageId: string, content: string) => void;
onCancelEdit: () => void;
onSaveEdit: (messageId: string) => void;
onSwitchVersion: (messageId: string, direction: "prev" | "next") => void;
}
export function MessageItem({
message,
isEditing,
editContent,
isLoading,
onEditContentChange,
onEditMessage,
onCancelEdit,
onSaveEdit,
onSwitchVersion,
}: MessageItemProps) {
if (isEditing) {
return (
<div className={`message ${message.role}`}>
<div className="message-edit-form">
<textarea
value={editContent}
onChange={(e) => onEditContentChange(e.target.value)}
className="message-edit-textarea"
autoFocus
/>
<div className="message-edit-actions">
<button className="edit-cancel-btn" onClick={onCancelEdit}>
Отмена
</button>
<button
className="edit-save-btn"
onClick={() => onSaveEdit(message.id)}
>
Сохранить и переиграть
</button>
</div>
</div>
</div>
);
}
return (
<div className={`message ${message.role}`}>
<div className="message-content">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
<div className="message-footer">
<span className="message-time">
{new Date(message.timestamp).toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
{message.role === "user" && !isLoading && (
<div className="message-actions">
{message.versions && message.versions.length > 1 && (
<div className="version-switcher">
<button
className="version-btn"
onClick={() => onSwitchVersion(message.id, "prev")}
>
</button>
<span className="version-indicator">
{(message.activeVersion || 0) + 1}/{message.versions.length}
</span>
<button
className="version-btn"
onClick={() => onSwitchVersion(message.id, "next")}
>
</button>
</div>
)}
<button
className="edit-btn"
onClick={() => onEditMessage(message.id, message.content)}
title="Редактировать"
>
</button>
</div>
)}
</div>
</div>
);
}
interface StreamingMessageProps {
content: string;
}
export function StreamingMessage({ content }: StreamingMessageProps) {
return (
<div className="message assistant streaming">
<div className="message-content">
<ReactMarkdown>{content}</ReactMarkdown>
</div>
</div>
);
}
export function LoadingMessage() {
return (
<div className="message assistant loading">
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
);
}
interface ErrorMessageProps {
error: string;
onDismiss: () => void;
}
export function ErrorMessage({ error, onDismiss }: ErrorMessageProps) {
return (
<div className="error-message">
<span> {error}</span>
<button onClick={onDismiss}></button>
</div>
);
}
interface MessageListProps {
messages: ChatMessage[];
streamingContent: string;
isLoading: boolean;
error: string | null;
editingMessageId: string | null;
editContent: string;
onEditContentChange: (content: string) => void;
onEditMessage: (messageId: string, content: string) => void;
onCancelEdit: () => void;
onSaveEdit: (messageId: string) => void;
onSwitchVersion: (messageId: string, direction: "prev" | "next") => void;
onDismissError: () => void;
messagesEndRef: React.RefObject<HTMLDivElement>;
messagesContainerRef: React.RefObject<HTMLDivElement>;
onScroll: () => void;
}
export function MessageList({
messages,
streamingContent,
isLoading,
error,
editingMessageId,
editContent,
onEditContentChange,
onEditMessage,
onCancelEdit,
onSaveEdit,
onSwitchVersion,
onDismissError,
messagesEndRef,
messagesContainerRef,
onScroll,
}: MessageListProps) {
return (
<div
className="messages-container"
ref={messagesContainerRef}
onScroll={onScroll}
>
{messages.map((message) => (
<MessageItem
key={message.id}
message={message}
isEditing={editingMessageId === message.id}
editContent={editContent}
isLoading={isLoading}
onEditContentChange={onEditContentChange}
onEditMessage={onEditMessage}
onCancelEdit={onCancelEdit}
onSaveEdit={onSaveEdit}
onSwitchVersion={onSwitchVersion}
/>
))}
{isLoading && streamingContent && (
<StreamingMessage content={streamingContent} />
)}
{isLoading && !streamingContent && <LoadingMessage />}
{error && <ErrorMessage error={error} onDismiss={onDismissError} />}
<div ref={messagesEndRef} />
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import React, { useState } from "react";
import type { SessionListItem } from "../../services/api";
interface SessionSelectorProps {
sessions: SessionListItem[];
currentSessionId: string | null;
currentSessionName: string;
onCreateNew: () => void;
onSwitch: (sessionId: string) => void;
onDelete: (sessionId: string, sessionName: string) => void;
}
export function SessionSelector({
sessions,
currentSessionId,
currentSessionName,
onCreateNew,
onSwitch,
onDelete,
}: SessionSelectorProps) {
const [showMenu, setShowMenu] = useState(false);
const handleSwitch = (sessionId: string) => {
setShowMenu(false);
onSwitch(sessionId);
};
const handleDelete = (
e: React.MouseEvent,
sessionId: string,
sessionName: string,
) => {
e.stopPropagation();
onDelete(sessionId, sessionName);
};
const handleCreateNew = () => {
setShowMenu(false);
onCreateNew();
};
return (
<div className="header-session">
<button
className="session-selector"
onClick={() => setShowMenu(!showMenu)}
>
📖 {currentSessionName}
</button>
{showMenu && (
<div className="session-menu">
<div className="session-menu-header">
<span>Сессии</span>
<button
className="new-session-btn"
onClick={handleCreateNew}
title="Новая сессия"
>
</button>
</div>
<div className="session-list">
{sessions.map((s) => (
<div
key={s.id}
className={`session-item ${s.id === currentSessionId ? "active" : ""}`}
>
<button
className="session-name"
onClick={() => handleSwitch(s.id)}
>
{s.name}
<span className="session-messages">
{s.messagesCount} сообщ.
</span>
</button>
{sessions.length > 1 && (
<button
className="session-delete-btn"
onClick={(e) => handleDelete(e, s.id, s.name)}
title="Удалить сессию"
>
🗑
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
export {
MessageList,
MessageItem,
StreamingMessage,
LoadingMessage,
ErrorMessage,
} from "./MessageList";
export { ChatInput } from "./ChatInput";
export { SessionSelector } from "./SessionSelector";
export { CharacterPanel } from "./CharacterPanel";
+3
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useContext,
@@ -50,6 +51,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (params.get("auth") === "success") {
// Убираем параметр из URL
window.history.replaceState({}, "", window.location.pathname);
// Refresh user after OAuth - intentional cascading render
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshUser();
}
}, []);
+4
View File
@@ -0,0 +1,4 @@
export { useCharacterDetection } from "./useCharacterDetection";
export { useStreamingResponse } from "./useStreamingResponse";
export { useGameSession } from "./useGameSession";
export { useLazyMessages } from "./useLazyMessages";
+57
View File
@@ -0,0 +1,57 @@
import { useMemo, useCallback } from "react";
import type { Character } from "../types";
/**
* Hook for detecting active character from message content
* Looks for character names in the first 200 characters of text
*/
export function useCharacterDetection(
characters: Character[] | undefined,
lastAssistantContent: string | undefined,
streamingContent: string | undefined,
) {
const detectActiveCharacter = useCallback(
(content: string): Character | null => {
if (!characters || characters.length === 0) return null;
const contentLower = content.toLowerCase();
for (const char of characters) {
if (!char.name) continue;
// Split name into parts (e.g., "Princess Lapis" -> ["princess", "lapis"])
const nameParts = char.name.toLowerCase().split(/\s+/);
for (const namePart of nameParts) {
if (namePart.length < 3) continue; // Skip short words
// Create regex to match whole word only
const wordBoundary = new RegExp(
`(^|[\\s.,!?;:"'«»—\\-])${namePart}([\\s.,!?;:"'«»—\\-]|$)`,
"i",
);
// Check if name appears as a whole word in first 200 characters
const firstPart = contentLower.substring(0, 200);
if (wordBoundary.test(firstPart)) {
return char;
}
}
}
return null;
},
[characters],
);
// Compute active character from streaming or last message content
const activeCharacter = useMemo(() => {
// Priority: streaming content > last assistant message
const content = streamingContent || lastAssistantContent;
if (!content) return null;
const detected = detectActiveCharacter(content);
return detected?.avatarUrl ? detected : null;
}, [streamingContent, lastAssistantContent, detectActiveCharacter]);
return { activeCharacter, detectActiveCharacter };
}
+300
View File
@@ -0,0 +1,300 @@
import { useState, useEffect, useRef, useCallback } from "react";
import {
getStory,
getSessionsList,
getSession,
createSession,
saveSession as apiSaveSession,
deleteSession,
getPlayerCharacter,
getPlayerCharacters,
type SessionListItem,
} from "../services/api";
import type { Story, GameSession, PlayerCharacter } from "../types";
interface UseGameSessionResult {
story: Story | null;
session: GameSession | null;
sessionsList: SessionListItem[];
currentSessionId: string | null;
playerCharacter: PlayerCharacter | null;
isInitialLoading: boolean;
// Actions
setSession: (session: GameSession | null) => void;
saveSession: (session: GameSession) => Promise<void>;
createNewSession: (characterId?: string) => Promise<SessionListItem | null>;
switchSession: (sessionId: string) => Promise<void>;
removeSession: (sessionId: string) => Promise<boolean>;
reloadStory: () => Promise<void>;
// For tracking unsaved changes
markUnsaved: () => void;
markSaved: () => void;
}
interface UseGameSessionOptions {
storyId: string | undefined;
characterIdFromUrl: string | null;
isAuthenticated: boolean;
}
/**
* Hook for managing game sessions, loading, saving, and session switching
*/
export function useGameSession({
storyId,
characterIdFromUrl,
isAuthenticated,
}: UseGameSessionOptions): UseGameSessionResult {
const [story, setStory] = useState<Story | null>(null);
const [sessionsList, setSessionsList] = useState<SessionListItem[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [session, setSession] = useState<GameSession | null>(null);
const [playerCharacter, setPlayerCharacter] =
useState<PlayerCharacter | null>(null);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const hasUnsavedChangesRef = useRef(false);
const sessionRef = useRef<GameSession | null>(null);
// Keep sessionRef in sync
useEffect(() => {
sessionRef.current = session;
}, [session]);
// Mark as unsaved
const markUnsaved = useCallback(() => {
hasUnsavedChangesRef.current = true;
}, []);
// Mark as saved
const markSaved = useCallback(() => {
hasUnsavedChangesRef.current = false;
}, []);
// Warn before leaving if there are unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChangesRef.current) {
e.preventDefault();
e.returnValue = "";
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, []);
// Save session to API
const saveSession = useCallback(
async (sessionData: GameSession) => {
if (!story || !currentSessionId) return;
await apiSaveSession(story.id, currentSessionId, sessionData);
hasUnsavedChangesRef.current = false;
},
[story, currentSessionId],
);
// Reload story data (e.g., after editing)
const reloadStory = useCallback(async () => {
if (!storyId || !isAuthenticated) return;
const updatedStory = await getStory(storyId);
if (updatedStory) {
const storyWithMongoId = updatedStory as Story & { _id?: string };
const normalizedStory = {
...updatedStory,
id: storyWithMongoId._id || updatedStory.id,
};
setStory(normalizedStory);
}
}, [storyId, isAuthenticated]);
// Create new session
const createNewSession = useCallback(
async (characterId?: string): Promise<SessionListItem | null> => {
if (!story || !storyId) return null;
const charId = characterId || playerCharacter?.id || session?.playerId;
if (!charId) return null;
const name = `Сессия ${sessionsList.length + 1}`;
const newSession = await createSession(storyId, name, charId);
if (newSession) {
setSessionsList((prev) => [newSession, ...prev]);
setCurrentSessionId(newSession.id);
const sessionData = await getSession(storyId, newSession.id);
if (sessionData) {
setSession(sessionData);
// Load character if not already loaded
if (!playerCharacter && charId) {
const character = await getPlayerCharacter(charId);
setPlayerCharacter(character);
}
}
return newSession;
}
return null;
},
[story, storyId, sessionsList, playerCharacter, session?.playerId],
);
// Switch to different session
const switchSession = useCallback(
async (sessionId: string) => {
if (!storyId || sessionId === currentSessionId) return;
setIsInitialLoading(true);
const sessionData = await getSession(storyId, sessionId);
if (sessionData) {
setCurrentSessionId(sessionId);
setSession(sessionData);
// Load session's character
if (sessionData.playerId) {
const character = await getPlayerCharacter(sessionData.playerId);
setPlayerCharacter(character);
}
}
setIsInitialLoading(false);
},
[storyId, currentSessionId],
);
// Delete session
const removeSession = useCallback(
async (sessionId: string): Promise<boolean> => {
if (!storyId) return false;
const success = await deleteSession(storyId, sessionId);
if (success) {
const newList = sessionsList.filter((s) => s.id !== sessionId);
setSessionsList(newList);
// If deleted current session, switch to first remaining
if (sessionId === currentSessionId && newList.length > 0) {
await switchSession(newList[0].id);
}
return true;
}
return false;
},
[storyId, sessionsList, currentSessionId, switchSession],
);
// Initial load
useEffect(() => {
const loadGame = async () => {
if (!storyId || !isAuthenticated) {
setIsInitialLoading(false);
return;
}
const foundStory = await getStory(storyId);
if (!foundStory) {
setIsInitialLoading(false);
return;
}
const storyWithMongoId = foundStory as Story & { _id?: string };
const normalizedStory = {
...foundStory,
id: storyWithMongoId._id || foundStory.id,
};
setStory(normalizedStory);
// Load sessions list
const sessions = await getSessionsList(storyId);
setSessionsList(sessions);
// Load character
let character: PlayerCharacter | null = null;
if (characterIdFromUrl) {
character = await getPlayerCharacter(characterIdFromUrl);
setPlayerCharacter(character);
} else {
const characters = await getPlayerCharacters();
if (characters.length > 0) {
character = characters.find((c) => c.isFavorite) || characters[0];
setPlayerCharacter(character);
}
}
// Load or create session
if (sessions.length > 0) {
const latestSession = sessions[0];
setCurrentSessionId(latestSession.id);
const sessionData = await getSession(storyId, latestSession.id);
if (sessionData) {
setSession(sessionData);
// Load character from session if not already loaded
if (!character && sessionData.playerId) {
character = await getPlayerCharacter(sessionData.playerId);
setPlayerCharacter(character);
}
// Update session with character if needed
if (!sessionData.playerId && character) {
const updatedSession = { ...sessionData, playerId: character.id };
await apiSaveSession(storyId, latestSession.id, updatedSession);
setSession(updatedSession);
}
}
} else if (characterIdFromUrl) {
// No sessions and character selected — create new
const newSession = await createSession(
storyId,
undefined,
characterIdFromUrl,
);
if (newSession) {
setSessionsList([newSession]);
setCurrentSessionId(newSession.id);
const sessionData = await getSession(storyId, newSession.id);
if (sessionData) {
setSession(sessionData);
}
}
}
setIsInitialLoading(false);
};
loadGame();
}, [storyId, isAuthenticated, characterIdFromUrl]);
// Reload story on focus (after editing)
useEffect(() => {
const handleFocus = () => {
if (!storyId || !isAuthenticated || isInitialLoading) return;
reloadStory();
};
window.addEventListener("focus", handleFocus);
return () => window.removeEventListener("focus", handleFocus);
}, [storyId, isAuthenticated, isInitialLoading, reloadStory]);
return {
story,
session,
sessionsList,
currentSessionId,
playerCharacter,
isInitialLoading,
setSession,
saveSession,
createNewSession,
switchSession,
removeSession,
reloadStory,
markUnsaved,
markSaved,
};
}
+121
View File
@@ -0,0 +1,121 @@
import { useState, useCallback, useRef, useMemo } from "react";
import type { ChatMessage } from "../types";
const INITIAL_VISIBLE_COUNT = 20;
const LOAD_MORE_COUNT = 20;
interface UseLazyMessagesResult {
visibleMessages: ChatMessage[];
hasHiddenMessages: boolean;
hiddenCount: number;
loadMore: () => void;
loadAll: () => void;
handleScroll: (container: HTMLElement | null) => void;
totalCount: number;
}
/**
* Hook for lazy loading messages - shows only recent messages by default,
* loads more when scrolling up or clicking "Load more".
* Uses sliding window - when new messages arrive, old ones are hidden again.
*/
export function useLazyMessages(
messages: ChatMessage[] | undefined,
sessionId: string | null,
): UseLazyMessagesResult {
// Track how many extra messages to show (beyond INITIAL_VISIBLE_COUNT)
const [stateKey, setStateKey] = useState({
sessionId,
extraVisible: 0,
prevTotalCount: 0,
});
const isLoadingMoreRef = useRef(false);
const totalCount = messages?.length || 0;
// Reset extraVisible when session changes OR when new messages arrive
let currentExtraVisible = stateKey.extraVisible;
if (sessionId !== stateKey.sessionId) {
// Session changed - reset everything
currentExtraVisible = 0;
setStateKey({ sessionId, extraVisible: 0, prevTotalCount: totalCount });
} else if (
totalCount > stateKey.prevTotalCount &&
stateKey.prevTotalCount > 0
) {
// New message arrived - reset to show only last 20
currentExtraVisible = 0;
setStateKey((prev) => ({
...prev,
extraVisible: 0,
prevTotalCount: totalCount,
}));
} else if (totalCount !== stateKey.prevTotalCount) {
// Total count changed (could be initial load) - just update the count
setStateKey((prev) => ({ ...prev, prevTotalCount: totalCount }));
}
// Sliding window: always show last INITIAL_VISIBLE_COUNT + extraVisible messages
const effectiveVisibleCount = useMemo(() => {
return Math.min(INITIAL_VISIBLE_COUNT + currentExtraVisible, totalCount);
}, [totalCount, currentExtraVisible]);
const startIndex = Math.max(0, totalCount - effectiveVisibleCount);
const visibleMessages = messages?.slice(startIndex) || [];
const hasHiddenMessages = startIndex > 0;
const hiddenCount = startIndex;
const loadMore = useCallback(() => {
setStateKey((prev) => ({
...prev,
extraVisible: prev.extraVisible + LOAD_MORE_COUNT,
}));
}, []);
const loadAll = useCallback(() => {
setStateKey((prev) => ({ ...prev, extraVisible: totalCount }));
}, [totalCount]);
// Handle scroll - load more when reaching top (with cooldown to prevent rapid loading)
const handleScroll = useCallback(
(container: HTMLElement | null) => {
if (!container || !hasHiddenMessages || isLoadingMoreRef.current) return;
const { scrollTop } = container;
// If scrolled near top (within 100px), load more
if (scrollTop < 100) {
isLoadingMoreRef.current = true;
// Remember current scroll position to maintain it after loading
const prevScrollHeight = container.scrollHeight;
loadMore();
// After DOM update, restore scroll position and add cooldown
requestAnimationFrame(() => {
const newScrollHeight = container.scrollHeight;
const scrollDiff = newScrollHeight - prevScrollHeight;
container.scrollTop = scrollTop + scrollDiff;
// Add 500ms cooldown to prevent rapid loading
setTimeout(() => {
isLoadingMoreRef.current = false;
}, 500);
});
}
},
[hasHiddenMessages, loadMore],
);
return {
visibleMessages,
hasHiddenMessages,
hiddenCount,
loadMore,
loadAll,
handleScroll,
totalCount,
};
}
+101
View File
@@ -0,0 +1,101 @@
import { useState, useRef, useCallback } from "react";
interface UseStreamingResponseResult {
streamingContent: string;
isStreaming: boolean;
updateStreamingContent: (chunk: string) => void;
flushStreamingContent: () => void;
resetStreaming: () => void;
startStreaming: () => AbortSignal;
abortController: React.MutableRefObject<AbortController | null>;
abort: () => void;
getLatestContent: () => string;
}
/**
* Hook for handling streaming AI responses with throttling
* Throttles updates to every 50ms to avoid excessive re-renders
*/
export function useStreamingResponse(): UseStreamingResponseResult {
const [streamingContent, setStreamingContent] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const streamingBufferRef = useRef("");
const lastUpdateRef = useRef(0);
const abortControllerRef = useRef<AbortController | null>(null);
// Start streaming - creates new AbortController and returns its signal
const startStreaming = useCallback(() => {
// Abort any existing request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new controller
abortControllerRef.current = new AbortController();
setIsStreaming(true);
streamingBufferRef.current = "";
setStreamingContent("");
return abortControllerRef.current.signal;
}, []);
// Throttled streaming update (every 50ms instead of every chunk)
const updateStreamingContent = useCallback((chunk: string) => {
streamingBufferRef.current += chunk;
setIsStreaming(true);
const now = Date.now();
if (now - lastUpdateRef.current > 50) {
setStreamingContent(streamingBufferRef.current);
lastUpdateRef.current = now;
}
}, []);
// Flush remaining content
const flushStreamingContent = useCallback(() => {
if (streamingBufferRef.current) {
setStreamingContent(streamingBufferRef.current);
}
}, []);
// Reset streaming state
const resetStreaming = useCallback(() => {
setStreamingContent("");
setIsStreaming(false);
streamingBufferRef.current = "";
lastUpdateRef.current = 0;
abortControllerRef.current = null;
}, []);
// Abort streaming
const abort = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
}, []);
// Get latest content from buffer (avoids stale closure issues)
const getLatestContent = useCallback(() => {
return streamingBufferRef.current;
}, []);
return {
streamingContent,
isStreaming,
updateStreamingContent,
flushStreamingContent,
resetStreaming,
startStreaming,
abortController: abortControllerRef,
abort,
getLatestContent,
};
}
/**
* Get current buffer content (for auto-save during streaming)
*/
export function getStreamingBuffer(
bufferRef: React.MutableRefObject<string>,
): string {
return bufferRef.current;
}
+1 -1
View File
@@ -43,7 +43,7 @@ export default function AdminPage() {
} else {
setError("Не удалось загрузить статистику");
}
} catch (err) {
} catch {
setError("Ошибка загрузки данных");
} finally {
setIsLoading(false);
+64
View File
@@ -289,6 +289,28 @@
box-sizing: border-box;
}
.load-more-messages-btn {
align-self: center;
padding: 0.5rem 1rem;
margin-bottom: 0.5rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 20px;
color: rgba(255, 255, 255, 0.7);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
}
.load-more-messages-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.9);
}
.load-more-messages-btn:active {
transform: scale(0.98);
}
.message {
max-width: 88%;
animation: fadeIn 0.25s ease;
@@ -710,6 +732,48 @@
background: #b91c1c;
}
/* OOC Mode Button */
.ooc-btn {
padding: 0.4rem 0.6rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
color: #888;
font-size: 0.7rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ooc-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #aaa;
}
.ooc-btn.active {
background: #f59e0b;
border-color: #f59e0b;
color: #000;
}
.ooc-btn.active:hover {
background: #d97706;
border-color: #d97706;
}
/* OOC Mode Input Container */
.input-container.ooc-mode {
border-color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
}
.input-container.ooc-mode textarea::placeholder {
color: #f59e0b;
}
/* Streaming message animation */
.message.streaming .message-text {
position: relative;
+232 -468
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -22,7 +22,7 @@ export default function StoriesPage() {
setIsLoading(true);
const data = await getStories();
// Преобразуем _id в id для совместимости
const normalizedStories = data.map((s: any) => ({
const normalizedStories = data.map((s: Story & { _id?: string }) => ({
...s,
id: s._id || s.id,
}));
+2 -1
View File
@@ -40,9 +40,10 @@ export default function StoryDetailPage() {
setIsLoading(true);
const foundStory = await getStory(id);
if (foundStory) {
const storyWithMongoId = foundStory as Story & { _id?: string };
setStory({
...foundStory,
id: (foundStory as any)._id || foundStory.id,
id: storyWithMongoId._id || foundStory.id,
});
const sessions = await getSessionsList(id);
if (sessions.length > 0) {
+8 -2
View File
@@ -302,7 +302,10 @@ export async function getPlayerCharacters(): Promise<PlayerCharacter[]> {
}
const data = await response.json();
return data.map((c: any) => ({ ...c, id: c._id || c.id }));
return data.map((c: PlayerCharacter & { _id?: string }) => ({
...c,
id: c._id || c.id,
}));
} catch (error) {
console.error("Failed to get characters:", error);
return [];
@@ -408,7 +411,10 @@ export async function getNPCCharacters(): Promise<NPCCharacter[]> {
}
const data = await response.json();
return data.map((c: any) => ({ ...c, id: c._id || c.id }));
return data.map((c: NPCCharacter & { _id?: string }) => ({
...c,
id: c._id || c.id,
}));
} catch (error) {
console.error("Failed to get NPCs:", error);
return [];
+6 -29
View File
@@ -1,4 +1,4 @@
// DeepSeek API service for story generation
// DeepSeek API service for story generation (via backend proxy)
import type {
Story,
@@ -7,15 +7,12 @@ import type {
GameSession,
} from "../types";
const DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";
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
// API key should be stored in environment variables
const getApiKey = () => import.meta.env.VITE_DEEPSEEK_API_KEY || "";
interface DeepSeekMessage {
role: "system" | "user" | "assistant";
content: string;
@@ -39,23 +36,13 @@ export async function sendMessage(
messages: DeepSeekMessage[],
temperature: number = 0.8,
): Promise<string> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error(
"DeepSeek API key not configured. Add VITE_DEEPSEEK_API_KEY to your .env file",
);
}
const response = await fetch(DEEPSEEK_API_URL, {
const response = await fetch(`${API_BASE}/api/deepseek/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
credentials: "include",
body: JSON.stringify({
// model: "deepseek-chat",
model: "deepseek-chat",
messages,
temperature,
max_tokens: 1000,
@@ -79,26 +66,16 @@ export async function sendMessageStream(
onChunk: (chunk: string) => void,
signal?: AbortSignal,
): Promise<string> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error(
"DeepSeek API key not configured. Add VITE_DEEPSEEK_API_KEY to your .env file",
);
}
const response = await fetch(DEEPSEEK_API_URL, {
const response = await fetch(`${API_BASE}/api/deepseek/chat/stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
credentials: "include",
body: JSON.stringify({
model: "deepseek-chat",
messages,
temperature,
max_tokens: 1000,
stream: true,
}),
signal,
});
+5 -29
View File
@@ -3,9 +3,6 @@
import type { CharacterAge, CharacterGender } from "../types";
const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001";
const DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
const getDeepSeekKey = () => import.meta.env.VITE_DEEPSEEK_API_KEY || "";
interface GenerateAvatarOptions {
description: string;
@@ -29,38 +26,17 @@ const GENDER_PROMPTS: Record<CharacterGender, string> = {
};
/**
* Translates Russian text to English using DeepSeek API
* Translates Russian text to English using DeepSeek API via backend
*/
async function translateToEnglish(text: string): Promise<string> {
const apiKey = getDeepSeekKey();
if (!apiKey) {
console.warn("No DeepSeek API key for translation");
return text;
}
try {
const response = await fetch(DEEPSEEK_API_URL, {
const response = await fetch(`${API_BASE}/api/deepseek/translate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: [
{
role: "system",
content:
"Translate to English for image generation. Output ONLY the translation, nothing else. Keep character names as-is. Be concise.",
},
{
role: "user",
content: text,
},
],
temperature: 0.1,
max_tokens: 150,
}),
credentials: "include",
body: JSON.stringify({ text }),
});
if (!response.ok) {
@@ -69,7 +45,7 @@ async function translateToEnglish(text: string): Promise<string> {
}
const data = await response.json();
const translated = data.choices?.[0]?.message?.content?.trim();
const translated = data.translated;
console.log("Translated prompt:", translated);
return translated || text;
} catch (error) {