feat: streaming AI responses with stop button
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует сводку истории (вызывать периодически)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user