Major refactor: security, performance, and code organization

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

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

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

Features:
- OOC mode button for direct AI instructions
- Message versions (aiResponse) now persist to DB
- playerId saved in sessions
This commit is contained in:
Alexej Wolff
2026-05-05 23:41:52 +02:00
parent bbefa114f8
commit 68c2b129fa
23 changed files with 1817 additions and 553 deletions
+19
View File
@@ -0,0 +1,19 @@
import type { Character } from "../../types";
interface CharacterPanelProps {
character: Character | null;
}
export function CharacterPanel({ character }: CharacterPanelProps) {
if (!character?.avatarUrl) return null;
return (
<div className="character-panel">
<img src={character.avatarUrl} alt={character.name} />
<div className="character-panel-info">
<span className="character-panel-name">{character.name}</span>
<span className="character-panel-role">{character.role}</span>
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import React, { useRef, useEffect } from "react";
interface ChatInputProps {
value: string;
onChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
isLoading: boolean;
disabled?: boolean;
placeholder?: string;
}
export function ChatInput({
value,
onChange,
onSend,
onStop,
isLoading,
disabled = false,
placeholder = "Что ты хочешь сделать?...",
}: ChatInputProps) {
const inputRef = useRef<HTMLTextAreaElement>(null);
// Focus input after loading completes
useEffect(() => {
if (!isLoading) {
inputRef.current?.focus();
}
}, [isLoading]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSend();
}
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
// Auto-resize
e.target.style.height = "auto";
e.target.style.height = Math.min(e.target.scrollHeight, 150) + "px";
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSend();
};
// Reset textarea height when value is cleared
useEffect(() => {
if (!value && inputRef.current) {
inputRef.current.style.height = "auto";
}
}, [value]);
return (
<form className="input-container" onSubmit={handleSubmit}>
<textarea
ref={inputRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isLoading || disabled}
rows={1}
name="chat-input"
autoComplete="off"
autoCorrect="on"
autoCapitalize="sentences"
spellCheck={true}
enterKeyHint="send"
data-form-type="other"
data-lpignore="true"
data-gramm="false"
/>
{isLoading ? (
<button type="button" onClick={onStop} className="send-btn stop-btn">
</button>
) : (
<button type="submit" disabled={!value.trim()} className="send-btn">
</button>
)}
</form>
);
}
+208
View File
@@ -0,0 +1,208 @@
import React from "react";
import ReactMarkdown from "react-markdown";
import type { ChatMessage } from "../../types";
interface MessageItemProps {
message: ChatMessage;
isEditing: boolean;
editContent: string;
isLoading: boolean;
onEditContentChange: (content: string) => void;
onEditMessage: (messageId: string, content: string) => void;
onCancelEdit: () => void;
onSaveEdit: (messageId: string) => void;
onSwitchVersion: (messageId: string, direction: "prev" | "next") => void;
}
export function MessageItem({
message,
isEditing,
editContent,
isLoading,
onEditContentChange,
onEditMessage,
onCancelEdit,
onSaveEdit,
onSwitchVersion,
}: MessageItemProps) {
if (isEditing) {
return (
<div className={`message ${message.role}`}>
<div className="message-edit-form">
<textarea
value={editContent}
onChange={(e) => onEditContentChange(e.target.value)}
className="message-edit-textarea"
autoFocus
/>
<div className="message-edit-actions">
<button className="edit-cancel-btn" onClick={onCancelEdit}>
Отмена
</button>
<button
className="edit-save-btn"
onClick={() => onSaveEdit(message.id)}
>
Сохранить и переиграть
</button>
</div>
</div>
</div>
);
}
return (
<div className={`message ${message.role}`}>
<div className="message-content">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
<div className="message-footer">
<span className="message-time">
{new Date(message.timestamp).toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
{message.role === "user" && !isLoading && (
<div className="message-actions">
{message.versions && message.versions.length > 1 && (
<div className="version-switcher">
<button
className="version-btn"
onClick={() => onSwitchVersion(message.id, "prev")}
>
</button>
<span className="version-indicator">
{(message.activeVersion || 0) + 1}/{message.versions.length}
</span>
<button
className="version-btn"
onClick={() => onSwitchVersion(message.id, "next")}
>
</button>
</div>
)}
<button
className="edit-btn"
onClick={() => onEditMessage(message.id, message.content)}
title="Редактировать"
>
</button>
</div>
)}
</div>
</div>
);
}
interface StreamingMessageProps {
content: string;
}
export function StreamingMessage({ content }: StreamingMessageProps) {
return (
<div className="message assistant streaming">
<div className="message-content">
<ReactMarkdown>{content}</ReactMarkdown>
</div>
</div>
);
}
export function LoadingMessage() {
return (
<div className="message assistant loading">
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
);
}
interface ErrorMessageProps {
error: string;
onDismiss: () => void;
}
export function ErrorMessage({ error, onDismiss }: ErrorMessageProps) {
return (
<div className="error-message">
<span> {error}</span>
<button onClick={onDismiss}></button>
</div>
);
}
interface MessageListProps {
messages: ChatMessage[];
streamingContent: string;
isLoading: boolean;
error: string | null;
editingMessageId: string | null;
editContent: string;
onEditContentChange: (content: string) => void;
onEditMessage: (messageId: string, content: string) => void;
onCancelEdit: () => void;
onSaveEdit: (messageId: string) => void;
onSwitchVersion: (messageId: string, direction: "prev" | "next") => void;
onDismissError: () => void;
messagesEndRef: React.RefObject<HTMLDivElement>;
messagesContainerRef: React.RefObject<HTMLDivElement>;
onScroll: () => void;
}
export function MessageList({
messages,
streamingContent,
isLoading,
error,
editingMessageId,
editContent,
onEditContentChange,
onEditMessage,
onCancelEdit,
onSaveEdit,
onSwitchVersion,
onDismissError,
messagesEndRef,
messagesContainerRef,
onScroll,
}: MessageListProps) {
return (
<div
className="messages-container"
ref={messagesContainerRef}
onScroll={onScroll}
>
{messages.map((message) => (
<MessageItem
key={message.id}
message={message}
isEditing={editingMessageId === message.id}
editContent={editContent}
isLoading={isLoading}
onEditContentChange={onEditContentChange}
onEditMessage={onEditMessage}
onCancelEdit={onCancelEdit}
onSaveEdit={onSaveEdit}
onSwitchVersion={onSwitchVersion}
/>
))}
{isLoading && streamingContent && (
<StreamingMessage content={streamingContent} />
)}
{isLoading && !streamingContent && <LoadingMessage />}
{error && <ErrorMessage error={error} onDismiss={onDismissError} />}
<div ref={messagesEndRef} />
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import React, { useState } from "react";
import type { SessionListItem } from "../../services/api";
interface SessionSelectorProps {
sessions: SessionListItem[];
currentSessionId: string | null;
currentSessionName: string;
onCreateNew: () => void;
onSwitch: (sessionId: string) => void;
onDelete: (sessionId: string, sessionName: string) => void;
}
export function SessionSelector({
sessions,
currentSessionId,
currentSessionName,
onCreateNew,
onSwitch,
onDelete,
}: SessionSelectorProps) {
const [showMenu, setShowMenu] = useState(false);
const handleSwitch = (sessionId: string) => {
setShowMenu(false);
onSwitch(sessionId);
};
const handleDelete = (
e: React.MouseEvent,
sessionId: string,
sessionName: string,
) => {
e.stopPropagation();
onDelete(sessionId, sessionName);
};
const handleCreateNew = () => {
setShowMenu(false);
onCreateNew();
};
return (
<div className="header-session">
<button
className="session-selector"
onClick={() => setShowMenu(!showMenu)}
>
📖 {currentSessionName}
</button>
{showMenu && (
<div className="session-menu">
<div className="session-menu-header">
<span>Сессии</span>
<button
className="new-session-btn"
onClick={handleCreateNew}
title="Новая сессия"
>
</button>
</div>
<div className="session-list">
{sessions.map((s) => (
<div
key={s.id}
className={`session-item ${s.id === currentSessionId ? "active" : ""}`}
>
<button
className="session-name"
onClick={() => handleSwitch(s.id)}
>
{s.name}
<span className="session-messages">
{s.messagesCount} сообщ.
</span>
</button>
{sessions.length > 1 && (
<button
className="session-delete-btn"
onClick={(e) => handleDelete(e, s.id, s.name)}
title="Удалить сессию"
>
🗑
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
export {
MessageList,
MessageItem,
StreamingMessage,
LoadingMessage,
ErrorMessage,
} from "./MessageList";
export { ChatInput } from "./ChatInput";
export { SessionSelector } from "./SessionSelector";
export { CharacterPanel } from "./CharacterPanel";
+3
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useContext,
@@ -50,6 +51,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (params.get("auth") === "success") {
// Убираем параметр из URL
window.history.replaceState({}, "", window.location.pathname);
// Refresh user after OAuth - intentional cascading render
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshUser();
}
}, []);
+4
View File
@@ -0,0 +1,4 @@
export { useCharacterDetection } from "./useCharacterDetection";
export { useStreamingResponse } from "./useStreamingResponse";
export { useGameSession } from "./useGameSession";
export { useLazyMessages } from "./useLazyMessages";
+57
View File
@@ -0,0 +1,57 @@
import { useMemo, useCallback } from "react";
import type { Character } from "../types";
/**
* Hook for detecting active character from message content
* Looks for character names in the first 200 characters of text
*/
export function useCharacterDetection(
characters: Character[] | undefined,
lastAssistantContent: string | undefined,
streamingContent: string | undefined,
) {
const detectActiveCharacter = useCallback(
(content: string): Character | null => {
if (!characters || characters.length === 0) return null;
const contentLower = content.toLowerCase();
for (const char of characters) {
if (!char.name) continue;
// Split name into parts (e.g., "Princess Lapis" -> ["princess", "lapis"])
const nameParts = char.name.toLowerCase().split(/\s+/);
for (const namePart of nameParts) {
if (namePart.length < 3) continue; // Skip short words
// Create regex to match whole word only
const wordBoundary = new RegExp(
`(^|[\\s.,!?;:"'«»—\\-])${namePart}([\\s.,!?;:"'«»—\\-]|$)`,
"i",
);
// Check if name appears as a whole word in first 200 characters
const firstPart = contentLower.substring(0, 200);
if (wordBoundary.test(firstPart)) {
return char;
}
}
}
return null;
},
[characters],
);
// Compute active character from streaming or last message content
const activeCharacter = useMemo(() => {
// Priority: streaming content > last assistant message
const content = streamingContent || lastAssistantContent;
if (!content) return null;
const detected = detectActiveCharacter(content);
return detected?.avatarUrl ? detected : null;
}, [streamingContent, lastAssistantContent, detectActiveCharacter]);
return { activeCharacter, detectActiveCharacter };
}
+300
View File
@@ -0,0 +1,300 @@
import { useState, useEffect, useRef, useCallback } from "react";
import {
getStory,
getSessionsList,
getSession,
createSession,
saveSession as apiSaveSession,
deleteSession,
getPlayerCharacter,
getPlayerCharacters,
type SessionListItem,
} from "../services/api";
import type { Story, GameSession, PlayerCharacter } from "../types";
interface UseGameSessionResult {
story: Story | null;
session: GameSession | null;
sessionsList: SessionListItem[];
currentSessionId: string | null;
playerCharacter: PlayerCharacter | null;
isInitialLoading: boolean;
// Actions
setSession: (session: GameSession | null) => void;
saveSession: (session: GameSession) => Promise<void>;
createNewSession: (characterId?: string) => Promise<SessionListItem | null>;
switchSession: (sessionId: string) => Promise<void>;
removeSession: (sessionId: string) => Promise<boolean>;
reloadStory: () => Promise<void>;
// For tracking unsaved changes
markUnsaved: () => void;
markSaved: () => void;
}
interface UseGameSessionOptions {
storyId: string | undefined;
characterIdFromUrl: string | null;
isAuthenticated: boolean;
}
/**
* Hook for managing game sessions, loading, saving, and session switching
*/
export function useGameSession({
storyId,
characterIdFromUrl,
isAuthenticated,
}: UseGameSessionOptions): UseGameSessionResult {
const [story, setStory] = useState<Story | null>(null);
const [sessionsList, setSessionsList] = useState<SessionListItem[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [session, setSession] = useState<GameSession | null>(null);
const [playerCharacter, setPlayerCharacter] =
useState<PlayerCharacter | null>(null);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const hasUnsavedChangesRef = useRef(false);
const sessionRef = useRef<GameSession | null>(null);
// Keep sessionRef in sync
useEffect(() => {
sessionRef.current = session;
}, [session]);
// Mark as unsaved
const markUnsaved = useCallback(() => {
hasUnsavedChangesRef.current = true;
}, []);
// Mark as saved
const markSaved = useCallback(() => {
hasUnsavedChangesRef.current = false;
}, []);
// Warn before leaving if there are unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChangesRef.current) {
e.preventDefault();
e.returnValue = "";
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, []);
// Save session to API
const saveSession = useCallback(
async (sessionData: GameSession) => {
if (!story || !currentSessionId) return;
await apiSaveSession(story.id, currentSessionId, sessionData);
hasUnsavedChangesRef.current = false;
},
[story, currentSessionId],
);
// Reload story data (e.g., after editing)
const reloadStory = useCallback(async () => {
if (!storyId || !isAuthenticated) return;
const updatedStory = await getStory(storyId);
if (updatedStory) {
const storyWithMongoId = updatedStory as Story & { _id?: string };
const normalizedStory = {
...updatedStory,
id: storyWithMongoId._id || updatedStory.id,
};
setStory(normalizedStory);
}
}, [storyId, isAuthenticated]);
// Create new session
const createNewSession = useCallback(
async (characterId?: string): Promise<SessionListItem | null> => {
if (!story || !storyId) return null;
const charId = characterId || playerCharacter?.id || session?.playerId;
if (!charId) return null;
const name = `Сессия ${sessionsList.length + 1}`;
const newSession = await createSession(storyId, name, charId);
if (newSession) {
setSessionsList((prev) => [newSession, ...prev]);
setCurrentSessionId(newSession.id);
const sessionData = await getSession(storyId, newSession.id);
if (sessionData) {
setSession(sessionData);
// Load character if not already loaded
if (!playerCharacter && charId) {
const character = await getPlayerCharacter(charId);
setPlayerCharacter(character);
}
}
return newSession;
}
return null;
},
[story, storyId, sessionsList, playerCharacter, session?.playerId],
);
// Switch to different session
const switchSession = useCallback(
async (sessionId: string) => {
if (!storyId || sessionId === currentSessionId) return;
setIsInitialLoading(true);
const sessionData = await getSession(storyId, sessionId);
if (sessionData) {
setCurrentSessionId(sessionId);
setSession(sessionData);
// Load session's character
if (sessionData.playerId) {
const character = await getPlayerCharacter(sessionData.playerId);
setPlayerCharacter(character);
}
}
setIsInitialLoading(false);
},
[storyId, currentSessionId],
);
// Delete session
const removeSession = useCallback(
async (sessionId: string): Promise<boolean> => {
if (!storyId) return false;
const success = await deleteSession(storyId, sessionId);
if (success) {
const newList = sessionsList.filter((s) => s.id !== sessionId);
setSessionsList(newList);
// If deleted current session, switch to first remaining
if (sessionId === currentSessionId && newList.length > 0) {
await switchSession(newList[0].id);
}
return true;
}
return false;
},
[storyId, sessionsList, currentSessionId, switchSession],
);
// Initial load
useEffect(() => {
const loadGame = async () => {
if (!storyId || !isAuthenticated) {
setIsInitialLoading(false);
return;
}
const foundStory = await getStory(storyId);
if (!foundStory) {
setIsInitialLoading(false);
return;
}
const storyWithMongoId = foundStory as Story & { _id?: string };
const normalizedStory = {
...foundStory,
id: storyWithMongoId._id || foundStory.id,
};
setStory(normalizedStory);
// Load sessions list
const sessions = await getSessionsList(storyId);
setSessionsList(sessions);
// Load character
let character: PlayerCharacter | null = null;
if (characterIdFromUrl) {
character = await getPlayerCharacter(characterIdFromUrl);
setPlayerCharacter(character);
} else {
const characters = await getPlayerCharacters();
if (characters.length > 0) {
character = characters.find((c) => c.isFavorite) || characters[0];
setPlayerCharacter(character);
}
}
// Load or create session
if (sessions.length > 0) {
const latestSession = sessions[0];
setCurrentSessionId(latestSession.id);
const sessionData = await getSession(storyId, latestSession.id);
if (sessionData) {
setSession(sessionData);
// Load character from session if not already loaded
if (!character && sessionData.playerId) {
character = await getPlayerCharacter(sessionData.playerId);
setPlayerCharacter(character);
}
// Update session with character if needed
if (!sessionData.playerId && character) {
const updatedSession = { ...sessionData, playerId: character.id };
await apiSaveSession(storyId, latestSession.id, updatedSession);
setSession(updatedSession);
}
}
} else if (characterIdFromUrl) {
// No sessions and character selected — create new
const newSession = await createSession(
storyId,
undefined,
characterIdFromUrl,
);
if (newSession) {
setSessionsList([newSession]);
setCurrentSessionId(newSession.id);
const sessionData = await getSession(storyId, newSession.id);
if (sessionData) {
setSession(sessionData);
}
}
}
setIsInitialLoading(false);
};
loadGame();
}, [storyId, isAuthenticated, characterIdFromUrl]);
// Reload story on focus (after editing)
useEffect(() => {
const handleFocus = () => {
if (!storyId || !isAuthenticated || isInitialLoading) return;
reloadStory();
};
window.addEventListener("focus", handleFocus);
return () => window.removeEventListener("focus", handleFocus);
}, [storyId, isAuthenticated, isInitialLoading, reloadStory]);
return {
story,
session,
sessionsList,
currentSessionId,
playerCharacter,
isInitialLoading,
setSession,
saveSession,
createNewSession,
switchSession,
removeSession,
reloadStory,
markUnsaved,
markSaved,
};
}
+121
View File
@@ -0,0 +1,121 @@
import { useState, useCallback, useRef, useMemo } from "react";
import type { ChatMessage } from "../types";
const INITIAL_VISIBLE_COUNT = 20;
const LOAD_MORE_COUNT = 20;
interface UseLazyMessagesResult {
visibleMessages: ChatMessage[];
hasHiddenMessages: boolean;
hiddenCount: number;
loadMore: () => void;
loadAll: () => void;
handleScroll: (container: HTMLElement | null) => void;
totalCount: number;
}
/**
* Hook for lazy loading messages - shows only recent messages by default,
* loads more when scrolling up or clicking "Load more".
* Uses sliding window - when new messages arrive, old ones are hidden again.
*/
export function useLazyMessages(
messages: ChatMessage[] | undefined,
sessionId: string | null,
): UseLazyMessagesResult {
// Track how many extra messages to show (beyond INITIAL_VISIBLE_COUNT)
const [stateKey, setStateKey] = useState({
sessionId,
extraVisible: 0,
prevTotalCount: 0,
});
const isLoadingMoreRef = useRef(false);
const totalCount = messages?.length || 0;
// Reset extraVisible when session changes OR when new messages arrive
let currentExtraVisible = stateKey.extraVisible;
if (sessionId !== stateKey.sessionId) {
// Session changed - reset everything
currentExtraVisible = 0;
setStateKey({ sessionId, extraVisible: 0, prevTotalCount: totalCount });
} else if (
totalCount > stateKey.prevTotalCount &&
stateKey.prevTotalCount > 0
) {
// New message arrived - reset to show only last 20
currentExtraVisible = 0;
setStateKey((prev) => ({
...prev,
extraVisible: 0,
prevTotalCount: totalCount,
}));
} else if (totalCount !== stateKey.prevTotalCount) {
// Total count changed (could be initial load) - just update the count
setStateKey((prev) => ({ ...prev, prevTotalCount: totalCount }));
}
// Sliding window: always show last INITIAL_VISIBLE_COUNT + extraVisible messages
const effectiveVisibleCount = useMemo(() => {
return Math.min(INITIAL_VISIBLE_COUNT + currentExtraVisible, totalCount);
}, [totalCount, currentExtraVisible]);
const startIndex = Math.max(0, totalCount - effectiveVisibleCount);
const visibleMessages = messages?.slice(startIndex) || [];
const hasHiddenMessages = startIndex > 0;
const hiddenCount = startIndex;
const loadMore = useCallback(() => {
setStateKey((prev) => ({
...prev,
extraVisible: prev.extraVisible + LOAD_MORE_COUNT,
}));
}, []);
const loadAll = useCallback(() => {
setStateKey((prev) => ({ ...prev, extraVisible: totalCount }));
}, [totalCount]);
// Handle scroll - load more when reaching top (with cooldown to prevent rapid loading)
const handleScroll = useCallback(
(container: HTMLElement | null) => {
if (!container || !hasHiddenMessages || isLoadingMoreRef.current) return;
const { scrollTop } = container;
// If scrolled near top (within 100px), load more
if (scrollTop < 100) {
isLoadingMoreRef.current = true;
// Remember current scroll position to maintain it after loading
const prevScrollHeight = container.scrollHeight;
loadMore();
// After DOM update, restore scroll position and add cooldown
requestAnimationFrame(() => {
const newScrollHeight = container.scrollHeight;
const scrollDiff = newScrollHeight - prevScrollHeight;
container.scrollTop = scrollTop + scrollDiff;
// Add 500ms cooldown to prevent rapid loading
setTimeout(() => {
isLoadingMoreRef.current = false;
}, 500);
});
}
},
[hasHiddenMessages, loadMore],
);
return {
visibleMessages,
hasHiddenMessages,
hiddenCount,
loadMore,
loadAll,
handleScroll,
totalCount,
};
}
+101
View File
@@ -0,0 +1,101 @@
import { useState, useRef, useCallback } from "react";
interface UseStreamingResponseResult {
streamingContent: string;
isStreaming: boolean;
updateStreamingContent: (chunk: string) => void;
flushStreamingContent: () => void;
resetStreaming: () => void;
startStreaming: () => AbortSignal;
abortController: React.MutableRefObject<AbortController | null>;
abort: () => void;
getLatestContent: () => string;
}
/**
* Hook for handling streaming AI responses with throttling
* Throttles updates to every 50ms to avoid excessive re-renders
*/
export function useStreamingResponse(): UseStreamingResponseResult {
const [streamingContent, setStreamingContent] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const streamingBufferRef = useRef("");
const lastUpdateRef = useRef(0);
const abortControllerRef = useRef<AbortController | null>(null);
// Start streaming - creates new AbortController and returns its signal
const startStreaming = useCallback(() => {
// Abort any existing request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new controller
abortControllerRef.current = new AbortController();
setIsStreaming(true);
streamingBufferRef.current = "";
setStreamingContent("");
return abortControllerRef.current.signal;
}, []);
// Throttled streaming update (every 50ms instead of every chunk)
const updateStreamingContent = useCallback((chunk: string) => {
streamingBufferRef.current += chunk;
setIsStreaming(true);
const now = Date.now();
if (now - lastUpdateRef.current > 50) {
setStreamingContent(streamingBufferRef.current);
lastUpdateRef.current = now;
}
}, []);
// Flush remaining content
const flushStreamingContent = useCallback(() => {
if (streamingBufferRef.current) {
setStreamingContent(streamingBufferRef.current);
}
}, []);
// Reset streaming state
const resetStreaming = useCallback(() => {
setStreamingContent("");
setIsStreaming(false);
streamingBufferRef.current = "";
lastUpdateRef.current = 0;
abortControllerRef.current = null;
}, []);
// Abort streaming
const abort = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
}, []);
// Get latest content from buffer (avoids stale closure issues)
const getLatestContent = useCallback(() => {
return streamingBufferRef.current;
}, []);
return {
streamingContent,
isStreaming,
updateStreamingContent,
flushStreamingContent,
resetStreaming,
startStreaming,
abortController: abortControllerRef,
abort,
getLatestContent,
};
}
/**
* Get current buffer content (for auto-save during streaming)
*/
export function getStreamingBuffer(
bufferRef: React.MutableRefObject<string>,
): string {
return bufferRef.current;
}
+1 -1
View File
@@ -43,7 +43,7 @@ export default function AdminPage() {
} else {
setError("Не удалось загрузить статистику");
}
} catch (err) {
} catch {
setError("Ошибка загрузки данных");
} finally {
setIsLoading(false);
+64
View File
@@ -289,6 +289,28 @@
box-sizing: border-box;
}
.load-more-messages-btn {
align-self: center;
padding: 0.5rem 1rem;
margin-bottom: 0.5rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 20px;
color: rgba(255, 255, 255, 0.7);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
}
.load-more-messages-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.9);
}
.load-more-messages-btn:active {
transform: scale(0.98);
}
.message {
max-width: 88%;
animation: fadeIn 0.25s ease;
@@ -710,6 +732,48 @@
background: #b91c1c;
}
/* OOC Mode Button */
.ooc-btn {
padding: 0.4rem 0.6rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
color: #888;
font-size: 0.7rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ooc-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #aaa;
}
.ooc-btn.active {
background: #f59e0b;
border-color: #f59e0b;
color: #000;
}
.ooc-btn.active:hover {
background: #d97706;
border-color: #d97706;
}
/* OOC Mode Input Container */
.input-container.ooc-mode {
border-color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
}
.input-container.ooc-mode textarea::placeholder {
color: #f59e0b;
}
/* Streaming message animation */
.message.streaming .message-text {
position: relative;
+232 -468
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -22,7 +22,7 @@ export default function StoriesPage() {
setIsLoading(true);
const data = await getStories();
// Преобразуем _id в id для совместимости
const normalizedStories = data.map((s: any) => ({
const normalizedStories = data.map((s: Story & { _id?: string }) => ({
...s,
id: s._id || s.id,
}));
+2 -1
View File
@@ -40,9 +40,10 @@ export default function StoryDetailPage() {
setIsLoading(true);
const foundStory = await getStory(id);
if (foundStory) {
const storyWithMongoId = foundStory as Story & { _id?: string };
setStory({
...foundStory,
id: (foundStory as any)._id || foundStory.id,
id: storyWithMongoId._id || foundStory.id,
});
const sessions = await getSessionsList(id);
if (sessions.length > 0) {
+8 -2
View File
@@ -302,7 +302,10 @@ export async function getPlayerCharacters(): Promise<PlayerCharacter[]> {
}
const data = await response.json();
return data.map((c: any) => ({ ...c, id: c._id || c.id }));
return data.map((c: PlayerCharacter & { _id?: string }) => ({
...c,
id: c._id || c.id,
}));
} catch (error) {
console.error("Failed to get characters:", error);
return [];
@@ -408,7 +411,10 @@ export async function getNPCCharacters(): Promise<NPCCharacter[]> {
}
const data = await response.json();
return data.map((c: any) => ({ ...c, id: c._id || c.id }));
return data.map((c: NPCCharacter & { _id?: string }) => ({
...c,
id: c._id || c.id,
}));
} catch (error) {
console.error("Failed to get NPCs:", error);
return [];
+6 -29
View File
@@ -1,4 +1,4 @@
// DeepSeek API service for story generation
// DeepSeek API service for story generation (via backend proxy)
import type {
Story,
@@ -7,15 +7,12 @@ import type {
GameSession,
} from "../types";
const DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";
const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001";
// Context settings
const RECENT_MESSAGES_COUNT = 6; // Last N messages for context
const SUMMARY_THRESHOLD = 15; // After how many messages to generate summary
// API key should be stored in environment variables
const getApiKey = () => import.meta.env.VITE_DEEPSEEK_API_KEY || "";
interface DeepSeekMessage {
role: "system" | "user" | "assistant";
content: string;
@@ -39,23 +36,13 @@ export async function sendMessage(
messages: DeepSeekMessage[],
temperature: number = 0.8,
): Promise<string> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error(
"DeepSeek API key not configured. Add VITE_DEEPSEEK_API_KEY to your .env file",
);
}
const response = await fetch(DEEPSEEK_API_URL, {
const response = await fetch(`${API_BASE}/api/deepseek/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
credentials: "include",
body: JSON.stringify({
// model: "deepseek-chat",
model: "deepseek-chat",
messages,
temperature,
max_tokens: 1000,
@@ -79,26 +66,16 @@ export async function sendMessageStream(
onChunk: (chunk: string) => void,
signal?: AbortSignal,
): Promise<string> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error(
"DeepSeek API key not configured. Add VITE_DEEPSEEK_API_KEY to your .env file",
);
}
const response = await fetch(DEEPSEEK_API_URL, {
const response = await fetch(`${API_BASE}/api/deepseek/chat/stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
credentials: "include",
body: JSON.stringify({
model: "deepseek-chat",
messages,
temperature,
max_tokens: 1000,
stream: true,
}),
signal,
});
+5 -29
View File
@@ -3,9 +3,6 @@
import type { CharacterAge, CharacterGender } from "../types";
const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001";
const DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
const getDeepSeekKey = () => import.meta.env.VITE_DEEPSEEK_API_KEY || "";
interface GenerateAvatarOptions {
description: string;
@@ -29,38 +26,17 @@ const GENDER_PROMPTS: Record<CharacterGender, string> = {
};
/**
* Translates Russian text to English using DeepSeek API
* Translates Russian text to English using DeepSeek API via backend
*/
async function translateToEnglish(text: string): Promise<string> {
const apiKey = getDeepSeekKey();
if (!apiKey) {
console.warn("No DeepSeek API key for translation");
return text;
}
try {
const response = await fetch(DEEPSEEK_API_URL, {
const response = await fetch(`${API_BASE}/api/deepseek/translate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: [
{
role: "system",
content:
"Translate to English for image generation. Output ONLY the translation, nothing else. Keep character names as-is. Be concise.",
},
{
role: "user",
content: text,
},
],
temperature: 0.1,
max_tokens: 150,
}),
credentials: "include",
body: JSON.stringify({ text }),
});
if (!response.ok) {
@@ -69,7 +45,7 @@ async function translateToEnglish(text: string): Promise<string> {
}
const data = await response.json();
const translated = data.choices?.[0]?.message?.content?.trim();
const translated = data.translated;
console.log("Translated prompt:", translated);
return translated || text;
} catch (error) {