e8cd01c693
- 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
1442 lines
42 KiB
JavaScript
1442 lines
42 KiB
JavaScript
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}`);
|
||
});
|
||
});
|