diff --git a/src/pages/GamePage.css b/src/pages/GamePage.css index 2d768c8..5ce174d 100644 --- a/src/pages/GamePage.css +++ b/src/pages/GamePage.css @@ -351,6 +351,40 @@ cursor: not-allowed; } +.stop-btn { + width: 50px; + min-width: 50px; + height: 50px; + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + border: none; + border-radius: 12px; + color: white; + font-size: 1.25rem; + cursor: pointer; + transition: transform 0.2s; + flex-shrink: 0; +} + +.stop-btn:hover { + transform: scale(1.05); +} + +/* Streaming message animation */ +.message.streaming .message-text { + position: relative; +} + +.message.streaming .message-text::after { + content: '▋'; + animation: blink 1s infinite; + margin-left: 2px; +} + +@keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} + /* Скроллбар */ .messages-container::-webkit-scrollbar { width: 6px; diff --git a/src/pages/GamePage.tsx b/src/pages/GamePage.tsx index 846dfa5..21017fe 100644 --- a/src/pages/GamePage.tsx +++ b/src/pages/GamePage.tsx @@ -9,7 +9,7 @@ import { getPlayerCharacter, } from "../services/api"; import { - generateStoryResponse, + generateStoryResponseStream, buildSystemPrompt, sendMessage, generateStorySummary, @@ -52,6 +52,8 @@ export default function GamePage() { const [isLoading, setIsLoading] = useState(false); const [isInitialLoading, setIsInitialLoading] = useState(true); const [error, setError] = useState(null); + const [streamingContent, setStreamingContent] = useState(""); + const abortControllerRef = useRef(null); const messagesEndRef = useRef(null); const inputRef = useRef(null); @@ -241,15 +243,23 @@ export default function GamePage() { setInput(""); setIsLoading(true); setError(null); + setStreamingContent(""); + + // Создаём AbortController для возможности отмены + abortControllerRef.current = new AbortController(); try { - // Передаём session для оптимизированного контекста - const response = await generateStoryResponse( + // Streaming ответ от AI + const response = await generateStoryResponseStream( story, session.messages, input.trim(), + (chunk) => { + setStreamingContent((prev) => prev + chunk); + }, playerCharacter || undefined, session, + abortControllerRef.current.signal, ); const assistantMessage: ChatMessage = { @@ -294,16 +304,41 @@ export default function GamePage() { ); setSession(finalSession); } catch (err) { - setError(err instanceof Error ? err.message : "Произошла ошибка"); - // Откатываем сообщение пользователя при ошибке - setSession(session); - setInput(userMessage.content); + if (err instanceof Error && err.name === "AbortError") { + // Пользователь отменил — сохраняем то что получили + if (streamingContent.trim()) { + const partialMessage: ChatMessage = { + id: generateId(), + role: "assistant", + content: streamingContent, + timestamp: new Date(), + }; + const partialSession: GameSession = { + ...session, + messages: [...updatedMessages, partialMessage], + }; + await apiSaveSession(story.id, partialSession); + setSession(partialSession); + } + } else { + setError(err instanceof Error ? err.message : "Произошла ошибка"); + setSession(session); + setInput(userMessage.content); + } } finally { setIsLoading(false); + setStreamingContent(""); + abortControllerRef.current = null; inputRef.current?.focus(); } }; + const handleStop = () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -369,7 +404,15 @@ export default function GamePage() { ))} - {isLoading && ( + {isLoading && streamingContent && ( +
+
+ {streamingContent} +
+
+ )} + + {isLoading && !streamingContent && (
@@ -416,13 +459,19 @@ export default function GamePage() { disabled={isLoading} rows={2} /> - + {isLoading ? ( + + ) : ( + + )}
diff --git a/src/services/deepseek.ts b/src/services/deepseek.ts index c15dd2f..4e8ec0a 100644 --- a/src/services/deepseek.ts +++ b/src/services/deepseek.ts @@ -82,6 +82,78 @@ export async function sendMessage( return data.choices[0]?.message?.content || ""; } +/** + * Streaming версия sendMessage - возвращает текст по частям + */ +export async function sendMessageStream( + messages: DeepSeekMessage[], + temperature: number = 0.8, + onChunk: (chunk: string) => void, + signal?: AbortSignal, +): Promise { + const apiKey = getApiKey(); + + if (!apiKey) { + throw new Error( + "DeepSeek API ключ не настроен. Добавьте VITE_DEEPSEEK_API_KEY в .env файл", + ); + } + + const response = await fetch(DEEPSEEK_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: "deepseek-chat", + messages, + temperature, + max_tokens: 1000, + stream: true, + }), + signal, + }); + + if (!response.ok) { + throw new Error(`DeepSeek API error: ${response.status}`); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + let fullContent = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n").filter((line) => line.trim() !== ""); + + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6); + if (data === "[DONE]") continue; + + try { + const parsed = JSON.parse(data); + const content = parsed.choices?.[0]?.delta?.content || ""; + if (content) { + fullContent += content; + onChunk(content); + } + } catch { + // Ignore parse errors + } + } + } + } + + return fullContent; +} + /** * Строит базовый системный промпт (правила стиля) - КЭШИРУЕТСЯ */ @@ -273,6 +345,36 @@ export async function generateStoryResponse( return sendMessage(messages, story.temperature || 1.3); } +/** + * Streaming версия generateStoryResponse + */ +export async function generateStoryResponseStream( + story: Story, + chatHistory: ChatMessage[], + userMessage: string, + onChunk: (chunk: string) => void, + player?: PlayerCharacter, + session?: GameSession, + signal?: AbortSignal, +): Promise { + const styleRules = buildStyleRules(story, player); + const worldContext = buildWorldContext(story); + const dynamicContext = session ? buildDynamicContext(session) : ""; + const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT); + const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext; + + const messages: DeepSeekMessage[] = [ + { role: "system", content: systemPrompt }, + ...recentMessages.map((msg) => ({ + role: msg.role as "user" | "assistant", + content: msg.content, + })), + { role: "user", content: userMessage }, + ]; + + return sendMessageStream(messages, story.temperature || 1.3, onChunk, signal); +} + /** * Генерирует сводку истории (вызывать периодически) */