Files
ReSekai/server/index.js
T
Alexej Wolff e8cd01c693 feat: banner generation, improved memory system, streaming text animation
- Add banner/cover generation for stories with character reference support
- Improve summary system: generate every 8 msgs or when context large
- Enhance summary prompt to preserve critical story info (promises, relationships)
- Add typewriter text animation during AI streaming
- Increase context to 20 messages, lower summary temperature to 0.1
- Server: auto-truncate long messages instead of rejecting
2026-05-09 06:39:10 +02:00

1442 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import express from "express";
import cors from "cors";
import session from "express-session";
import MongoStore from "connect-mongo";
import { MongoClient, ObjectId } from "mongodb";
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;
// MongoDB подключение
const mongoClient = new MongoClient(process.env.MONGODB_URI);
let db;
async function connectDB() {
await mongoClient.connect();
db = mongoClient.db("resekai");
console.log("✅ Connected to MongoDB");
}
// Middleware
// Trust proxy in production (needed for secure cookies behind nginx)
if (isProduction) {
app.set("trust proxy", 1);
}
app.use(express.json({ limit: "10mb" }));
app.use(
cors({
origin: process.env.FRONTEND_URL,
credentials: true,
}),
);
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: MongoStore.create({
mongoUrl: process.env.MONGODB_URI,
dbName: "resekai",
collectionName: "sessions",
}),
cookie: {
secure: isProduction, // true в production с HTTPS
httpOnly: true,
sameSite: "lax", // "lax" needed for OAuth redirects from Discord
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
},
}),
);
// Discord OAuth2 конфиг
const DISCORD_API = "https://discord.com/api/v10";
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID;
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET;
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: 50000, // ~12k tokens per message (increased for long context)
MAX_TOTAL_LENGTH: 200000, // ~50k tokens total (increased for world state + summary)
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 - only check critical errors, truncation handled in sanitize
if (!Array.isArray(messages)) {
errors.push("messages must be an array");
} else {
if (messages.length === 0) {
errors.push("messages cannot be empty");
}
// Don't error on too many messages - we'll truncate them
// Don't error on too long messages - we'll truncate them
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`);
}
}
}
// 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;
}
/**
* Truncate ONLY individual messages that are too long.
* Does NOT remove messages - that would lose context.
* If total is too long, client should handle via summary generation.
*/
function truncateMessagesToFit(messages) {
const MAX_PER_MSG = DEEPSEEK_LIMITS.MAX_MESSAGE_LENGTH;
// Only truncate individual messages that are too long - keep the END (recent context)
return messages.map((msg, idx) => {
if (msg.content && msg.content.length > MAX_PER_MSG) {
console.log(
`Truncating message ${idx} (${msg.role}) from ${msg.content.length} to ${MAX_PER_MSG} chars - keeping END`,
);
// Keep end of message (most recent context is more important)
return {
...msg,
content:
"[earlier content truncated]...\n\n" +
msg.content.slice(-MAX_PER_MSG + 50),
};
}
return msg;
});
}
function sanitizeDeepSeekMessages(messages) {
// First truncate to fit limits
const truncatedMessages = truncateMessagesToFit(messages);
return truncatedMessages.map((msg) => ({
role: msg.role,
content: String(msg.content),
}));
}
// Начало авторизации Discord
app.get("/auth/discord", (req, res) => {
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
redirect_uri: DISCORD_REDIRECT_URI,
response_type: "code",
scope: "identify email",
});
res.redirect(`https://discord.com/api/oauth2/authorize?${params}`);
});
// Callback от Discord
app.get("/auth/discord/callback", async (req, res) => {
const { code } = req.query;
if (!code) {
return res.redirect(`${process.env.FRONTEND_URL}?error=no_code`);
}
try {
// Получаем токен
const tokenResponse = await fetch(`${DISCORD_API}/oauth2/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
client_secret: DISCORD_CLIENT_SECRET,
grant_type: "authorization_code",
code,
redirect_uri: DISCORD_REDIRECT_URI,
}),
});
const tokenData = await tokenResponse.json();
if (!tokenData.access_token) {
console.error("Token error:", tokenData);
return res.redirect(`${process.env.FRONTEND_URL}?error=token_failed`);
}
// Получаем информацию о пользователе
const userResponse = await fetch(`${DISCORD_API}/users/@me`, {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
});
const discordUser = await userResponse.json();
// Сохраняем или обновляем пользователя в БД
const users = db.collection("users");
const existingUser = await users.findOne({ discordId: discordUser.id });
let user;
if (existingUser) {
// Обновляем существующего пользователя
await users.updateOne(
{ discordId: discordUser.id },
{
$set: {
username: discordUser.username,
email: discordUser.email,
avatar: discordUser.avatar,
updatedAt: new Date(),
},
},
);
user = await users.findOne({ discordId: discordUser.id });
} else {
// Создаём нового пользователя
const newUser = {
discordId: discordUser.id,
username: discordUser.username,
email: discordUser.email,
avatar: discordUser.avatar,
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await users.insertOne(newUser);
user = { ...newUser, _id: result.insertedId };
}
// Сохраняем в сессию
req.session.userId = user._id.toString();
req.session.discordId = discordUser.id;
res.redirect(`${process.env.FRONTEND_URL}?auth=success`);
} catch (error) {
console.error("Auth error:", error);
res.redirect(`${process.env.FRONTEND_URL}?error=auth_failed`);
}
});
// Получить текущего пользователя
app.get("/auth/me", async (req, res) => {
if (!req.session.userId) {
return res.json({ user: null });
}
try {
const users = db.collection("users");
const user = await users.findOne({ _id: new ObjectId(req.session.userId) });
if (!user) {
return res.json({ user: null });
}
res.json({
user: {
id: user._id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatar: user.avatar,
},
});
} catch (error) {
console.error("Get user error:", error);
res.json({ user: null });
}
});
// Выход
app.post("/auth/logout", (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: "Logout failed" });
}
res.clearCookie("connect.sid");
res.json({ success: true });
});
});
// ============ STORIES ROUTES ============
// Middleware для проверки авторизации
const requireAuth = (req, res, next) => {
if (!req.session.userId) {
return res.status(401).json({ error: "Unauthorized" });
}
next();
};
// Получить все истории пользователя
app.get("/api/stories", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
const userStories = await stories
.find({ userId: req.session.userId })
.sort({ updatedAt: -1 })
.toArray();
res.json(userStories);
} catch (error) {
console.error("Get stories error:", error);
res.status(500).json({ error: "Failed to get stories" });
}
});
// Получить одну историю
app.get("/api/stories/:id", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
const story = await stories.findOne({
_id: new ObjectId(req.params.id),
userId: req.session.userId,
});
if (!story) {
return res.status(404).json({ error: "Story not found" });
}
res.json(story);
} catch (error) {
console.error("Get story error:", error);
res.status(500).json({ error: "Failed to get story" });
}
});
// Создать историю
app.post("/api/stories", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
const allowedData = pickAllowedFields(req.body, ALLOWED_STORY_FIELDS);
const newStory = {
...allowedData,
userId: req.session.userId,
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await stories.insertOne(newStory);
res.json({ ...newStory, _id: result.insertedId });
} catch (error) {
console.error("Create story error:", error);
res.status(500).json({ error: "Failed to create story" });
}
});
// Обновить историю
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),
userId: req.session.userId,
},
{
$set: {
...allowedData,
updatedAt: new Date(),
},
},
);
if (result.matchedCount === 0) {
return res.status(404).json({ error: "Story not found" });
}
res.json({ success: true });
} catch (error) {
console.error("Update story error:", error);
res.status(500).json({ error: "Failed to update story" });
}
});
// Удалить историю
app.delete("/api/stories/:id", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
const result = await stories.deleteOne({
_id: new ObjectId(req.params.id),
userId: req.session.userId,
});
if (result.deletedCount === 0) {
return res.status(404).json({ error: "Story not found" });
}
// Также удаляем связанные сессии игры
const sessions = db.collection("game_sessions");
await sessions.deleteMany({ storyId: req.params.id });
res.json({ success: true });
} catch (error) {
console.error("Delete story error:", error);
res.status(500).json({ error: "Failed to delete story" });
}
});
// ============ GAME SESSIONS ROUTES ============
// Получить список сессий для истории
app.get("/api/sessions/:storyId", requireAuth, async (req, res) => {
try {
const sessions = db.collection("game_sessions");
const userSessions = await sessions
.find({
storyId: req.params.storyId,
userId: req.session.userId,
})
.sort({ updatedAt: -1 })
.toArray();
// Возвращаем список с базовой инфой (без полных сообщений)
const sessionsList = userSessions.map((s) => ({
id: s._id.toString(),
name: s.name || "Сессия",
messagesCount: s.messages?.length || 0,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
}));
res.json(sessionsList);
} catch (error) {
console.error("Get sessions list error:", error);
res.status(500).json({ error: "Failed to get sessions" });
}
});
// Получить конкретную сессию
app.get("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => {
try {
const sessions = db.collection("game_sessions");
const session = await sessions.findOne({
_id: new ObjectId(req.params.sessionId),
storyId: req.params.storyId,
userId: req.session.userId,
});
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
res.json({ ...session, id: session._id.toString() });
} catch (error) {
console.error("Get session error:", error);
res.status(500).json({ error: "Failed to get session" });
}
});
// Создать новую сессию
app.post("/api/sessions/:storyId", requireAuth, async (req, res) => {
try {
const sessions = db.collection("game_sessions");
// Считаем существующие сессии для нумерации
const existingCount = await sessions.countDocuments({
storyId: req.params.storyId,
userId: req.session.userId,
});
const sessionData = {
storyId: req.params.storyId,
userId: req.session.userId,
name: req.body.name || `Сессия ${existingCount + 1}`,
playerId: req.body.playerId || null,
messages: [],
currentState: {
location: "start",
health: 100,
inventory: [],
questProgress: {},
},
storySummary: "",
keyEvents: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await sessions.insertOne(sessionData);
res.json({
id: result.insertedId.toString(),
name: sessionData.name,
messagesCount: 0,
createdAt: sessionData.createdAt,
updatedAt: sessionData.updatedAt,
});
} catch (error) {
console.error("Create session error:", error);
res.status(500).json({ error: "Failed to create session" });
}
});
// Обновить сессию
app.put("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => {
try {
const sessions = db.collection("game_sessions");
// Получаем старую сессию для подсчёта новых токенов
const oldSession = await sessions.findOne({
_id: new ObjectId(req.params.sessionId),
userId: req.session.userId,
});
const oldMessageCount = oldSession?.messages?.length || 0;
// Фильтруем разрешенные поля
const allowedData = pickAllowedFields(req.body, ALLOWED_SESSION_FIELDS);
// Преобразуем 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 = {
...allowedData,
updatedAt: new Date(),
};
const result = await sessions.updateOne(
{
_id: new ObjectId(req.params.sessionId),
storyId: req.params.storyId,
userId: req.session.userId,
},
{ $set: sessionData },
);
if (result.matchedCount === 0) {
return res.status(404).json({ error: "Session not found" });
}
// Логируем новые токены (только для новых сообщений)
const messages = allowedData.messages || [];
const newMessages = messages.slice(oldMessageCount);
if (newMessages.length > 0) {
const tokenUsage = db.collection("token_usage");
const newTokens = newMessages.reduce((sum, msg) => {
return sum + Math.round((msg.content?.length || 0) / 3);
}, 0);
if (newTokens > 0) {
await tokenUsage.insertOne({
userId: req.session.userId,
storyId: req.params.storyId,
sessionId: req.params.sessionId,
tokens: newTokens,
messageCount: newMessages.length,
createdAt: new Date(),
});
}
}
res.json({ success: true });
} catch (error) {
console.error("Update session error:", error);
res.status(500).json({ error: "Failed to update session" });
}
});
// Удалить сессию
app.delete(
"/api/sessions/:storyId/:sessionId",
requireAuth,
async (req, res) => {
try {
const sessions = db.collection("game_sessions");
const result = await sessions.deleteOne({
_id: new ObjectId(req.params.sessionId),
storyId: req.params.storyId,
userId: req.session.userId,
});
if (result.deletedCount === 0) {
return res.status(404).json({ error: "Session not found" });
}
res.json({ success: true });
} catch (error) {
console.error("Delete session error:", error);
res.status(500).json({ error: "Failed to delete session" });
}
},
);
// ============ PLAYER CHARACTERS ROUTES ============
// Получить всех персонажей пользователя
app.get("/api/characters", requireAuth, async (req, res) => {
try {
const characters = db.collection("player_characters");
const userCharacters = await characters
.find({ userId: req.session.userId })
.sort({ updatedAt: -1 })
.toArray();
res.json(userCharacters);
} catch (error) {
console.error("Get characters error:", error);
res.status(500).json({ error: "Failed to get characters" });
}
});
// Получить одного персонажа
app.get("/api/characters/:id", requireAuth, async (req, res) => {
try {
const characters = db.collection("player_characters");
const character = await characters.findOne({
_id: new ObjectId(req.params.id),
userId: req.session.userId,
});
if (!character) {
return res.status(404).json({ error: "Character not found" });
}
res.json(character);
} catch (error) {
console.error("Get character error:", error);
res.status(500).json({ error: "Failed to get character" });
}
});
// Создать персонажа
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 = {
...allowedData,
userId: req.session.userId,
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await characters.insertOne(newCharacter);
res.json({ ...newCharacter, _id: result.insertedId });
} catch (error) {
console.error("Create character error:", error);
res.status(500).json({ error: "Failed to create character" });
}
});
// Обновить персонажа
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),
userId: req.session.userId,
},
{
$set: {
...allowedData,
updatedAt: new Date(),
},
},
);
if (result.matchedCount === 0) {
return res.status(404).json({ error: "Character not found" });
}
res.json({ success: true });
} catch (error) {
console.error("Update character error:", error);
res.status(500).json({ error: "Failed to update character" });
}
});
// Удалить персонажа
app.delete("/api/characters/:id", requireAuth, async (req, res) => {
try {
const characters = db.collection("player_characters");
const result = await characters.deleteOne({
_id: new ObjectId(req.params.id),
userId: req.session.userId,
});
if (result.deletedCount === 0) {
return res.status(404).json({ error: "Character not found" });
}
res.json({ success: true });
} catch (error) {
console.error("Delete character error:", error);
res.status(500).json({ error: "Failed to delete character" });
}
});
// ============ NPC CHARACTERS ROUTES ============
// Получить всех NPC пользователя
app.get("/api/npc", requireAuth, async (req, res) => {
try {
const npcCharacters = db.collection("npc_characters");
const userNPCs = await npcCharacters
.find({ userId: req.session.userId })
.sort({ updatedAt: -1 })
.toArray();
res.json(userNPCs);
} catch (error) {
console.error("Get NPCs error:", error);
res.status(500).json({ error: "Failed to get NPCs" });
}
});
// Получить одного NPC
app.get("/api/npc/:id", requireAuth, async (req, res) => {
try {
const npcCharacters = db.collection("npc_characters");
const npc = await npcCharacters.findOne({
_id: new ObjectId(req.params.id),
userId: req.session.userId,
});
if (!npc) {
return res.status(404).json({ error: "NPC not found" });
}
res.json(npc);
} catch (error) {
console.error("Get NPC error:", error);
res.status(500).json({ error: "Failed to get NPC" });
}
});
// Создать NPC
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 = {
...allowedData,
userId: req.session.userId,
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await npcCharacters.insertOne(newNPC);
res.json({ ...newNPC, _id: result.insertedId });
} catch (error) {
console.error("Create NPC error:", error);
res.status(500).json({ error: "Failed to create NPC" });
}
});
// Обновить NPC
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),
userId: req.session.userId,
},
{
$set: {
...allowedData,
updatedAt: new Date(),
},
},
);
if (result.matchedCount === 0) {
return res.status(404).json({ error: "NPC not found" });
}
res.json({ success: true });
} catch (error) {
console.error("Update NPC error:", error);
res.status(500).json({ error: "Failed to update NPC" });
}
});
// Удалить NPC
app.delete("/api/npc/:id", requireAuth, async (req, res) => {
try {
const npcCharacters = db.collection("npc_characters");
const result = await npcCharacters.deleteOne({
_id: new ObjectId(req.params.id),
userId: req.session.userId,
});
if (result.deletedCount === 0) {
return res.status(404).json({ error: "NPC not found" });
}
res.json({ success: true });
} catch (error) {
console.error("Delete NPC error:", error);
res.status(500).json({ error: "Failed to delete NPC" });
}
});
// ============ ADMIN STATS ============
// Получить статистику по всем историям и токенам
app.get("/api/admin/stats", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
const gameSessions = db.collection("game_sessions");
const tokenUsage = db.collection("token_usage");
// Получаем все истории пользователя
const userStories = await stories
.find({ userId: req.session.userId })
.toArray();
// Получаем все сессии пользователя
const userSessions = await gameSessions
.find({ userId: req.session.userId })
.toArray();
// Получаем общее количество токенов из лога (не зависит от удалённых сессий)
const tokenLogs = await tokenUsage
.find({ userId: req.session.userId })
.toArray();
const totalTokensLogged = tokenLogs.reduce(
(sum, log) => sum + (log.tokens || 0),
0,
);
// Считаем токены в текущих сессиях (для сравнения)
const currentTokens = userSessions.reduce((sum, session) => {
const chars = (session.messages || []).reduce(
(s, msg) => s + (msg.content?.length || 0),
0,
);
return sum + Math.round(chars / 3);
}, 0);
// Берём максимум: залогированные или текущие (для обратной совместимости)
const totalTokens = Math.max(totalTokensLogged, currentTokens);
// Считаем статистику для каждой истории
const storyStats = userStories.map((story) => {
const storySessions = userSessions.filter(
(s) => s.storyId === story._id.toString(),
);
const messageCount = storySessions.reduce(
(sum, s) => sum + (s.messages?.length || 0),
0,
);
// Токены из лога для этой истории
const storyTokenLogs = tokenLogs.filter(
(l) => l.storyId === story._id.toString(),
);
const loggedTokens = storyTokenLogs.reduce(
(sum, l) => sum + (l.tokens || 0),
0,
);
// Текущие токены в сессиях
const currentStoryTokens = storySessions.reduce((sum, session) => {
const chars = (session.messages || []).reduce(
(s, msg) => s + (msg.content?.length || 0),
0,
);
return sum + Math.round(chars / 3);
}, 0);
const tokens = Math.max(loggedTokens, currentStoryTokens);
const lastSession = storySessions.sort(
(a, b) => new Date(b.updatedAt) - new Date(a.updatedAt),
)[0];
return {
id: story._id.toString(),
title: story.title,
sessionsCount: storySessions.length,
messageCount,
tokens,
lastPlayed: lastSession?.updatedAt || null,
};
});
// Сортируем по токенам (больше сверху)
storyStats.sort((a, b) => b.tokens - a.tokens);
res.json({
totalStories: userStories.length,
totalSessions: userSessions.length,
totalTokens,
stories: storyStats,
});
} catch (error) {
console.error("Get admin stats error:", error);
res.status(500).json({ error: "Failed to get stats" });
}
});
// ============ IMAGE GENERATION ============
// Прокси для генерации изображений через Grok (обход CORS)
app.post("/api/generate-image", requireAuth, async (req, res) => {
try {
const { prompt, orientation = "portrait", refHistory } = req.body;
const apiKey = process.env.GEMINIGEN_API_KEY;
if (!apiKey) {
return res
.status(500)
.json({ error: "GeminiGen API key not configured" });
}
console.log(
"Generating image with Grok, prompt:",
prompt,
"orientation:",
orientation,
"refHistory:",
refHistory,
);
// Используем FormData для multipart/form-data
const formData = new FormData();
formData.append("prompt", prompt);
formData.append("orientation", orientation); // portrait (9:16) or landscape (16:9)
formData.append("num_result", "1");
// Добавляем референс истории если есть (для консистентности персонажей)
if (refHistory) {
formData.append("ref_history", refHistory);
console.log("Using ref_history:", refHistory);
}
const response = await fetch(
"https://api.geminigen.ai/uapi/v1/imagen/grok",
{
method: "POST",
headers: {
"x-api-key": apiKey,
},
body: formData,
},
);
if (!response.ok) {
const error = await response.text();
console.error("Grok API error:", error);
return res
.status(response.status)
.json({ error: "Image generation failed", details: error });
}
const data = await response.json();
console.log("Grok response:", data);
// Проверяем статус генерации
if (data.status === 2 && data.generate_result) {
// Готово - возвращаем URL
res.json({ url: data.generate_result });
} else if (data.status === 1) {
// В процессе - возвращаем uuid для polling
res.json({
pending: true,
uuid: data.uuid,
status_percentage: data.status_percentage,
});
} else {
res
.status(500)
.json({ error: data.error_message || "Generation failed" });
}
} catch (error) {
console.error("Image generation error:", error);
res.status(500).json({ error: "Failed to generate image" });
}
});
// Проверка статуса генерации
app.get("/api/generate-image/status/:uuid", requireAuth, async (req, res) => {
try {
const apiKey = process.env.GEMINIGEN_API_KEY;
const { uuid } = req.params;
const response = await fetch(
`https://api.geminigen.ai/uapi/v1/history/${uuid}`,
{
headers: {
"x-api-key": apiKey,
},
},
);
if (!response.ok) {
const errorText = await response.text();
console.error("History API error:", response.status, errorText);
return res
.status(response.status)
.json({ error: "Failed to check status" });
}
const data = await response.json();
console.log(
"History API status:",
data.status,
"images:",
data.generated_image?.length,
);
if (data.status === 2) {
// Completed - get image URL from generated_image array
const imageUrl =
data.generated_image?.[0]?.image_url ||
data.generated_image?.[0]?.file_download_url ||
data.generate_result;
if (imageUrl) {
res.json({ url: imageUrl, done: true });
} else {
res.status(500).json({ error: "No image URL in response" });
}
} else if (data.status === 1) {
res.json({ pending: true, status_percentage: data.status_percentage });
} else if (data.status === 3) {
res
.status(500)
.json({ error: data.error_message || "Generation failed" });
} else {
// Unknown status - keep polling
res.json({ pending: true, status_percentage: data.status_percentage });
}
} catch (error) {
console.error("Status check error:", error);
res.status(500).json({ error: "Failed to check status" });
}
});
// ============ 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) {
console.warn("DeepSeek chat validation failed:", validationErrors);
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; charset=utf-8",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: sanitizedMessages,
temperature,
max_tokens: clampedMaxTokens,
top_p: 0.95,
}),
});
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) {
console.warn("DeepSeek stream validation failed:", validationErrors);
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; charset=utf-8",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: sanitizedMessages,
temperature,
max_tokens: clampedMaxTokens,
top_p: 0.95,
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; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Pipe the stream
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
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; charset=utf-8",
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}`);
});
});