feat: streaming AI responses with stop button

This commit is contained in:
Alexej Wolff
2026-02-11 01:30:56 +01:00
parent 715f2a9bcf
commit 161ecd661e
3 changed files with 200 additions and 15 deletions
+34
View File
@@ -351,6 +351,40 @@
cursor: not-allowed; 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 { .messages-container::-webkit-scrollbar {
width: 6px; width: 6px;
+56 -7
View File
@@ -9,7 +9,7 @@ import {
getPlayerCharacter, getPlayerCharacter,
} from "../services/api"; } from "../services/api";
import { import {
generateStoryResponse, generateStoryResponseStream,
buildSystemPrompt, buildSystemPrompt,
sendMessage, sendMessage,
generateStorySummary, generateStorySummary,
@@ -52,6 +52,8 @@ export default function GamePage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true); const [isInitialLoading, setIsInitialLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [streamingContent, setStreamingContent] = useState("");
const abortControllerRef = useRef<AbortController | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -241,15 +243,23 @@ export default function GamePage() {
setInput(""); setInput("");
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setStreamingContent("");
// Создаём AbortController для возможности отмены
abortControllerRef.current = new AbortController();
try { try {
// Передаём session для оптимизированного контекста // Streaming ответ от AI
const response = await generateStoryResponse( const response = await generateStoryResponseStream(
story, story,
session.messages, session.messages,
input.trim(), input.trim(),
(chunk) => {
setStreamingContent((prev) => prev + chunk);
},
playerCharacter || undefined, playerCharacter || undefined,
session, session,
abortControllerRef.current.signal,
); );
const assistantMessage: ChatMessage = { const assistantMessage: ChatMessage = {
@@ -294,16 +304,41 @@ export default function GamePage() {
); );
setSession(finalSession); setSession(finalSession);
} catch (err) { } catch (err) {
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 : "Произошла ошибка"); setError(err instanceof Error ? err.message : "Произошла ошибка");
// Откатываем сообщение пользователя при ошибке
setSession(session); setSession(session);
setInput(userMessage.content); setInput(userMessage.content);
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
setStreamingContent("");
abortControllerRef.current = null;
inputRef.current?.focus(); inputRef.current?.focus();
} }
}; };
const handleStop = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -369,7 +404,15 @@ export default function GamePage() {
</div> </div>
))} ))}
{isLoading && ( {isLoading && streamingContent && (
<div className="message assistant streaming">
<div className="message-content">
<ReactMarkdown>{streamingContent}</ReactMarkdown>
</div>
</div>
)}
{isLoading && !streamingContent && (
<div className="message assistant loading"> <div className="message assistant loading">
<div className="typing-indicator"> <div className="typing-indicator">
<span></span> <span></span>
@@ -416,13 +459,19 @@ export default function GamePage() {
disabled={isLoading} disabled={isLoading}
rows={2} rows={2}
/> />
{isLoading ? (
<button onClick={handleStop} className="send-btn stop-btn">
</button>
) : (
<button <button
onClick={handleSend} onClick={handleSend}
disabled={!input.trim() || isLoading} disabled={!input.trim()}
className="send-btn" className="send-btn"
> >
{isLoading ? "⏳" : "➤"}
</button> </button>
)}
</div> </div>
</div> </div>
</div> </div>
+102
View File
@@ -82,6 +82,78 @@ export async function sendMessage(
return data.choices[0]?.message?.content || ""; 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<string> {
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); 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<string> {
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);
}
/** /**
* Генерирует сводку истории (вызывать периодически) * Генерирует сводку истории (вызывать периодически)
*/ */