feat: auto-resize textarea, persistent token stats

This commit is contained in:
Alexej Wolff
2026-02-11 02:05:28 +01:00
parent 8c6d6591f8
commit 863cf7f6b6
3 changed files with 96 additions and 19 deletions
+79 -13
View File
@@ -409,6 +409,13 @@ app.put("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => {
try { try {
const sessions = db.collection("game_sessions"); 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;
// Преобразуем timestamp строки обратно в Date // Преобразуем timestamp строки обратно в Date
const messages = (req.body.messages || []).map((msg) => ({ const messages = (req.body.messages || []).map((msg) => ({
...msg, ...msg,
@@ -437,6 +444,26 @@ app.put("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => {
return res.status(404).json({ error: "Session not found" }); return res.status(404).json({ error: "Session not found" });
} }
// Логируем новые токены (только для новых сообщений)
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 }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error("Update session error:", error); console.error("Update session error:", error);
@@ -582,6 +609,7 @@ app.get("/api/admin/stats", requireAuth, async (req, res) => {
try { try {
const stories = db.collection("stories"); const stories = db.collection("stories");
const gameSessions = db.collection("game_sessions"); const gameSessions = db.collection("game_sessions");
const tokenUsage = db.collection("token_usage");
// Получаем все истории пользователя // Получаем все истории пользователя
const userStories = await stories const userStories = await stories
@@ -590,39 +618,77 @@ app.get("/api/admin/stats", requireAuth, async (req, res) => {
// Получаем все сессии пользователя // Получаем все сессии пользователя
const userSessions = await gameSessions const userSessions = await gameSessions
.find({ storyId: { $in: userStories.map((s) => s._id.toString()) } }) .find({ userId: req.session.userId })
.toArray(); .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 storyStats = userStories.map((story) => {
const session = userSessions.find( const storySessions = userSessions.filter(
(s) => s.storyId === story._id.toString(), (s) => s.storyId === story._id.toString(),
); );
const messages = session?.messages || []; const messageCount = storySessions.reduce(
const messageCount = messages.length; (sum, s) => sum + (s.messages?.length || 0),
// Примерный подсчёт токенов (1 токен ≈ 3 символа для русского)
const totalChars = messages.reduce(
(sum, msg) => sum + (msg.content?.length || 0),
0, 0,
); );
const tokens = Math.round(totalChars / 3);
// Токены из лога для этой истории
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 { return {
id: story._id.toString(), id: story._id.toString(),
title: story.title, title: story.title,
sessionsCount: storySessions.length,
messageCount, messageCount,
tokens, tokens,
lastPlayed: session?.updatedAt || null, lastPlayed: lastSession?.updatedAt || null,
}; };
}); });
// Сортируем по токенам (больше сверху) // Сортируем по токенам (больше сверху)
storyStats.sort((a, b) => b.tokens - a.tokens); storyStats.sort((a, b) => b.tokens - a.tokens);
// Общая статистика
const totalTokens = storyStats.reduce((sum, s) => sum + s.tokens, 0);
res.json({ res.json({
totalStories: userStories.length, totalStories: userStories.length,
totalSessions: userSessions.length, totalSessions: userSessions.length,
+6 -4
View File
@@ -420,9 +420,9 @@
.input-container textarea { .input-container textarea {
flex: 1; flex: 1;
min-height: 50px; min-height: 44px;
max-height: 120px; max-height: 150px;
padding: 0.875rem 1rem; padding: 0.75rem 1rem;
background: #1a1a1a; background: #1a1a1a;
border: 2px solid #333; border: 2px solid #333;
border-radius: 12px; border-radius: 12px;
@@ -430,7 +430,9 @@
font-size: 1rem; font-size: 1rem;
font-family: inherit; font-family: inherit;
resize: none; resize: none;
transition: border-color 0.2s; transition: border-color 0.2s, height 0.1s ease;
overflow-y: auto;
line-height: 1.4;
} }
.input-container textarea:focus { .input-container textarea:focus {
+11 -2
View File
@@ -240,6 +240,10 @@ export default function GamePage() {
const tempSession = { ...session, messages: updatedMessages }; const tempSession = { ...session, messages: updatedMessages };
setSession(tempSession); setSession(tempSession);
setInput(""); setInput("");
// Reset textarea height
if (inputRef.current) {
inputRef.current.style.height = "auto";
}
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setStreamingContent(""); setStreamingContent("");
@@ -588,11 +592,16 @@ export default function GamePage() {
<textarea <textarea
ref={inputRef} ref={inputRef}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => {
setInput(e.target.value);
// Auto-resize
e.target.style.height = "auto";
e.target.style.height = Math.min(e.target.scrollHeight, 150) + "px";
}}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Что ты хочешь сделать?..." placeholder="Что ты хочешь сделать?..."
disabled={isLoading} disabled={isLoading}
rows={2} rows={1}
/> />
{isLoading ? ( {isLoading ? (
<button onClick={handleStop} className="send-btn stop-btn"> <button onClick={handleStop} className="send-btn stop-btn">