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;
}
.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;
+64 -15
View File
@@ -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<string | null>(null);
const [streamingContent, setStreamingContent] = useState("");
const abortControllerRef = useRef<AbortController | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(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() {
</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="typing-indicator">
<span></span>
@@ -416,13 +459,19 @@ export default function GamePage() {
disabled={isLoading}
rows={2}
/>
<button
onClick={handleSend}
disabled={!input.trim() || isLoading}
className="send-btn"
>
{isLoading ? "⏳" : "➤"}
</button>
{isLoading ? (
<button onClick={handleStop} className="send-btn stop-btn">
</button>
) : (
<button
onClick={handleSend}
disabled={!input.trim()}
className="send-btn"
>
</button>
)}
</div>
</div>
</div>