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:
@@ -1,5 +1,2 @@
|
|||||||
# DeepSeek API ключ для генерации историй
|
|
||||||
VITE_DEEPSEEK_API_KEY=your_api_key_here
|
|
||||||
|
|
||||||
# URL бэкенд сервера
|
# URL бэкенд сервера
|
||||||
VITE_API_URL=http://localhost:3001
|
VITE_API_URL=http://localhost:3001
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ cd resekai
|
|||||||
npm install
|
npm install
|
||||||
cd server && npm install && cd ..
|
cd server && npm install && cd ..
|
||||||
|
|
||||||
# Создать .env файл
|
# Настроить backend (скопировать .env.example и заполнить)
|
||||||
echo "VITE_DEEPSEEK_API_KEY=your_api_key" > .env
|
# Обязательно указать DEEPSEEK_API_KEY в server/.env
|
||||||
|
|
||||||
# Запустить в режиме разработки
|
# Запустить в режиме разработки
|
||||||
npm run dev # Frontend (порт 5173)
|
npm run dev # Frontend (порт 5173)
|
||||||
|
|||||||
@@ -14,3 +14,10 @@ FRONTEND_URL=http://localhost:5174
|
|||||||
|
|
||||||
# Server
|
# Server
|
||||||
PORT=3001
|
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
@@ -7,6 +7,30 @@ import dotenv from "dotenv";
|
|||||||
|
|
||||||
dotenv.config();
|
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 app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
@@ -21,7 +45,7 @@ async function connectDB() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: "10mb" }));
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: process.env.FRONTEND_URL,
|
origin: process.env.FRONTEND_URL,
|
||||||
@@ -40,8 +64,9 @@ app.use(
|
|||||||
collectionName: "sessions",
|
collectionName: "sessions",
|
||||||
}),
|
}),
|
||||||
cookie: {
|
cookie: {
|
||||||
secure: false, // true в production с HTTPS
|
secure: isProduction, // true в production с HTTPS
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
sameSite: isProduction ? "strict" : "lax",
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -55,6 +80,192 @@ const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI;
|
|||||||
|
|
||||||
// ============ AUTH ROUTES ============
|
// ============ 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
|
// Начало авторизации Discord
|
||||||
app.get("/auth/discord", (req, res) => {
|
app.get("/auth/discord", (req, res) => {
|
||||||
const params = new URLSearchParams({
|
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) => {
|
app.post("/api/stories", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const stories = db.collection("stories");
|
const stories = db.collection("stories");
|
||||||
|
const allowedData = pickAllowedFields(req.body, ALLOWED_STORY_FIELDS);
|
||||||
const newStory = {
|
const newStory = {
|
||||||
...req.body,
|
...allowedData,
|
||||||
userId: req.session.userId,
|
userId: req.session.userId,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: 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) => {
|
app.put("/api/stories/:id", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const stories = db.collection("stories");
|
const stories = db.collection("stories");
|
||||||
|
const allowedData = pickAllowedFields(req.body, ALLOWED_STORY_FIELDS);
|
||||||
|
|
||||||
const result = await stories.updateOne(
|
const result = await stories.updateOne(
|
||||||
{
|
{
|
||||||
_id: new ObjectId(req.params.id),
|
_id: new ObjectId(req.params.id),
|
||||||
@@ -267,7 +481,7 @@ app.put("/api/stories/:id", requireAuth, async (req, res) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
...req.body,
|
...allowedData,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -416,18 +630,40 @@ app.put("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
const oldMessageCount = oldSession?.messages?.length || 0;
|
const oldMessageCount = oldSession?.messages?.length || 0;
|
||||||
|
|
||||||
// Преобразуем timestamp строки обратно в Date
|
// Фильтруем разрешенные поля
|
||||||
const messages = (req.body.messages || []).map((msg) => ({
|
const allowedData = pickAllowedFields(req.body, ALLOWED_SESSION_FIELDS);
|
||||||
...msg,
|
|
||||||
timestamp: new Date(msg.timestamp),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Убираем _id и createdAt из body чтобы не было конфликтов
|
// Преобразуем timestamp строки обратно в Date
|
||||||
const { createdAt, _id, id, ...bodyWithoutMeta } = req.body;
|
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 = {
|
const sessionData = {
|
||||||
...bodyWithoutMeta,
|
...allowedData,
|
||||||
messages,
|
|
||||||
updatedAt: new Date(),
|
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);
|
const newMessages = messages.slice(oldMessageCount);
|
||||||
if (newMessages.length > 0) {
|
if (newMessages.length > 0) {
|
||||||
const tokenUsage = db.collection("token_usage");
|
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) => {
|
app.post("/api/characters", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const characters = db.collection("player_characters");
|
const characters = db.collection("player_characters");
|
||||||
|
const allowedData = pickAllowedFields(req.body, ALLOWED_CHARACTER_FIELDS);
|
||||||
const newCharacter = {
|
const newCharacter = {
|
||||||
...req.body,
|
...allowedData,
|
||||||
userId: req.session.userId,
|
userId: req.session.userId,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: 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) => {
|
app.put("/api/characters/:id", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const characters = db.collection("player_characters");
|
const characters = db.collection("player_characters");
|
||||||
|
const allowedData = pickAllowedFields(req.body, ALLOWED_CHARACTER_FIELDS);
|
||||||
|
|
||||||
const result = await characters.updateOne(
|
const result = await characters.updateOne(
|
||||||
{
|
{
|
||||||
_id: new ObjectId(req.params.id),
|
_id: new ObjectId(req.params.id),
|
||||||
@@ -565,7 +805,7 @@ app.put("/api/characters/:id", requireAuth, async (req, res) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
...req.body,
|
...allowedData,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -644,8 +884,9 @@ app.get("/api/npc/:id", requireAuth, async (req, res) => {
|
|||||||
app.post("/api/npc", requireAuth, async (req, res) => {
|
app.post("/api/npc", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const npcCharacters = db.collection("npc_characters");
|
const npcCharacters = db.collection("npc_characters");
|
||||||
|
const allowedData = pickAllowedFields(req.body, ALLOWED_NPC_FIELDS);
|
||||||
const newNPC = {
|
const newNPC = {
|
||||||
...req.body,
|
...allowedData,
|
||||||
userId: req.session.userId,
|
userId: req.session.userId,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: 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) => {
|
app.put("/api/npc/:id", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const npcCharacters = db.collection("npc_characters");
|
const npcCharacters = db.collection("npc_characters");
|
||||||
|
const allowedData = pickAllowedFields(req.body, ALLOWED_NPC_FIELDS);
|
||||||
|
|
||||||
const result = await npcCharacters.updateOne(
|
const result = await npcCharacters.updateOne(
|
||||||
{
|
{
|
||||||
_id: new ObjectId(req.params.id),
|
_id: new ObjectId(req.params.id),
|
||||||
@@ -670,7 +913,7 @@ app.put("/api/npc/:id", requireAuth, async (req, res) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
...req.body,
|
...allowedData,
|
||||||
updatedAt: new Date(),
|
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(() => {
|
connectDB().then(() => {
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`🚀 Server running on http://localhost:${PORT}`);
|
console.log(`🚀 Server running on http://localhost:${PORT}`);
|
||||||
|
console.log(` Mode: ${isProduction ? "production" : "development"}`);
|
||||||
|
console.log(` Secure cookies: ${isProduction}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export {
|
||||||
|
MessageList,
|
||||||
|
MessageItem,
|
||||||
|
StreamingMessage,
|
||||||
|
LoadingMessage,
|
||||||
|
ErrorMessage,
|
||||||
|
} from "./MessageList";
|
||||||
|
export { ChatInput } from "./ChatInput";
|
||||||
|
export { SessionSelector } from "./SessionSelector";
|
||||||
|
export { CharacterPanel } from "./CharacterPanel";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
@@ -50,6 +51,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
if (params.get("auth") === "success") {
|
if (params.get("auth") === "success") {
|
||||||
// Убираем параметр из URL
|
// Убираем параметр из URL
|
||||||
window.history.replaceState({}, "", window.location.pathname);
|
window.history.replaceState({}, "", window.location.pathname);
|
||||||
|
// Refresh user after OAuth - intentional cascading render
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
refreshUser();
|
refreshUser();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { useCharacterDetection } from "./useCharacterDetection";
|
||||||
|
export { useStreamingResponse } from "./useStreamingResponse";
|
||||||
|
export { useGameSession } from "./useGameSession";
|
||||||
|
export { useLazyMessages } from "./useLazyMessages";
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ export default function AdminPage() {
|
|||||||
} else {
|
} else {
|
||||||
setError("Не удалось загрузить статистику");
|
setError("Не удалось загрузить статистику");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError("Ошибка загрузки данных");
|
setError("Ошибка загрузки данных");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@@ -289,6 +289,28 @@
|
|||||||
box-sizing: border-box;
|
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 {
|
.message {
|
||||||
max-width: 88%;
|
max-width: 88%;
|
||||||
animation: fadeIn 0.25s ease;
|
animation: fadeIn 0.25s ease;
|
||||||
@@ -710,6 +732,48 @@
|
|||||||
background: #b91c1c;
|
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 */
|
/* Streaming message animation */
|
||||||
.message.streaming .message-text {
|
.message.streaming .message-text {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
+232
-468
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ export default function StoriesPage() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const data = await getStories();
|
const data = await getStories();
|
||||||
// Преобразуем _id в id для совместимости
|
// Преобразуем _id в id для совместимости
|
||||||
const normalizedStories = data.map((s: any) => ({
|
const normalizedStories = data.map((s: Story & { _id?: string }) => ({
|
||||||
...s,
|
...s,
|
||||||
id: s._id || s.id,
|
id: s._id || s.id,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -40,9 +40,10 @@ export default function StoryDetailPage() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const foundStory = await getStory(id);
|
const foundStory = await getStory(id);
|
||||||
if (foundStory) {
|
if (foundStory) {
|
||||||
|
const storyWithMongoId = foundStory as Story & { _id?: string };
|
||||||
setStory({
|
setStory({
|
||||||
...foundStory,
|
...foundStory,
|
||||||
id: (foundStory as any)._id || foundStory.id,
|
id: storyWithMongoId._id || foundStory.id,
|
||||||
});
|
});
|
||||||
const sessions = await getSessionsList(id);
|
const sessions = await getSessionsList(id);
|
||||||
if (sessions.length > 0) {
|
if (sessions.length > 0) {
|
||||||
|
|||||||
+8
-2
@@ -302,7 +302,10 @@ export async function getPlayerCharacters(): Promise<PlayerCharacter[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to get characters:", error);
|
console.error("Failed to get characters:", error);
|
||||||
return [];
|
return [];
|
||||||
@@ -408,7 +411,10 @@ export async function getNPCCharacters(): Promise<NPCCharacter[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to get NPCs:", error);
|
console.error("Failed to get NPCs:", error);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// DeepSeek API service for story generation
|
// DeepSeek API service for story generation (via backend proxy)
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Story,
|
Story,
|
||||||
@@ -7,15 +7,12 @@ import type {
|
|||||||
GameSession,
|
GameSession,
|
||||||
} from "../types";
|
} 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
|
// Context settings
|
||||||
const RECENT_MESSAGES_COUNT = 6; // Last N messages for context
|
const RECENT_MESSAGES_COUNT = 6; // Last N messages for context
|
||||||
const SUMMARY_THRESHOLD = 15; // After how many messages to generate summary
|
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 {
|
interface DeepSeekMessage {
|
||||||
role: "system" | "user" | "assistant";
|
role: "system" | "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
@@ -39,23 +36,13 @@ export async function sendMessage(
|
|||||||
messages: DeepSeekMessage[],
|
messages: DeepSeekMessage[],
|
||||||
temperature: number = 0.8,
|
temperature: number = 0.8,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const apiKey = getApiKey();
|
const response = await fetch(`${API_BASE}/api/deepseek/chat`, {
|
||||||
|
|
||||||
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, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
},
|
||||||
|
credentials: "include",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
// model: "deepseek-chat",
|
|
||||||
model: "deepseek-chat",
|
|
||||||
messages,
|
messages,
|
||||||
temperature,
|
temperature,
|
||||||
max_tokens: 1000,
|
max_tokens: 1000,
|
||||||
@@ -79,26 +66,16 @@ export async function sendMessageStream(
|
|||||||
onChunk: (chunk: string) => void,
|
onChunk: (chunk: string) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const apiKey = getApiKey();
|
const response = await fetch(`${API_BASE}/api/deepseek/chat/stream`, {
|
||||||
|
|
||||||
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, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
},
|
||||||
|
credentials: "include",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: "deepseek-chat",
|
|
||||||
messages,
|
messages,
|
||||||
temperature,
|
temperature,
|
||||||
max_tokens: 1000,
|
max_tokens: 1000,
|
||||||
stream: true,
|
|
||||||
}),
|
}),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,9 +3,6 @@
|
|||||||
import type { CharacterAge, CharacterGender } from "../types";
|
import type { CharacterAge, CharacterGender } from "../types";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001";
|
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 {
|
interface GenerateAvatarOptions {
|
||||||
description: string;
|
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> {
|
async function translateToEnglish(text: string): Promise<string> {
|
||||||
const apiKey = getDeepSeekKey();
|
|
||||||
if (!apiKey) {
|
|
||||||
console.warn("No DeepSeek API key for translation");
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(DEEPSEEK_API_URL, {
|
const response = await fetch(`${API_BASE}/api/deepseek/translate`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
credentials: "include",
|
||||||
model: "deepseek-chat",
|
body: JSON.stringify({ text }),
|
||||||
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) {
|
if (!response.ok) {
|
||||||
@@ -69,7 +45,7 @@ async function translateToEnglish(text: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const translated = data.choices?.[0]?.message?.content?.trim();
|
const translated = data.translated;
|
||||||
console.log("Translated prompt:", translated);
|
console.log("Translated prompt:", translated);
|
||||||
return translated || text;
|
return translated || text;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user