diff --git a/backend/app/api/routes/chat.py b/backend/app/api/routes/chat.py index 7637590..6b85cce 100644 --- a/backend/app/api/routes/chat.py +++ b/backend/app/api/routes/chat.py @@ -48,8 +48,15 @@ async def send_message( if not service.get_session(session_id): raise HTTPException(status_code=404, detail="Session not found") + # Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД. + service.save_user_message(session_id, payload.content) + async def event_stream(): - async for chunk in service.stream_response(session_id, payload.content): + async for chunk in service.stream_response( + session_id, + payload.content, + user_message_saved=True, + ): yield chunk return StreamingResponse( diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py index 85d8da1..ae7f028 100644 --- a/backend/app/chat/service.py +++ b/backend/app/chat/service.py @@ -172,13 +172,23 @@ class ChatService: self.db.refresh(message) return message - async def stream_response(self, session_id: int, user_text: str) -> AsyncIterator[str]: + def save_user_message(self, session_id: int, user_text: str) -> None: + self._save_message(session_id, "user", user_text) + + async def stream_response( + self, + session_id: int, + user_text: str, + *, + user_message_saved: bool = False, + ) -> AsyncIterator[str]: session = self.get_session(session_id) if not session: yield self._sse("error", {"message": "Session not found"}) return - self._save_message(session_id, "user", user_text) + if not user_message_saved: + self._save_message(session_id, "user", user_text) yield self._sse("status", {"phase": "preparing"}) t0 = time.monotonic() messages = await asyncio.to_thread(_build_messages_for_session, session_id) @@ -299,10 +309,15 @@ class ChatService: if not final_content and reasoning: final_content = reasoning.strip() if not final_content and all_tool_notices: - # Notices уже ушли в SSE event: notice; здесь только финальный текст в БД. final_content = "\n\n".join(all_tool_notices) + yield self._sse("token", {"content": final_content}) if not final_content and tools_executed: - retry = await self.llm.complete(messages, tools=None, temperature=0.4) + retry = await self.llm.complete( + messages, + tools=None, + temperature=0.4, + visible_reply=True, + ) final_content = (retry.get("content") or "").strip() if final_content: yield self._sse("token", {"content": final_content}) diff --git a/backend/app/llm/client.py b/backend/app/llm/client.py index bcfb36d..2e522a7 100644 --- a/backend/app/llm/client.py +++ b/backend/app/llm/client.py @@ -171,6 +171,7 @@ class LLMClient: temperature: float = 0.7, model: str | None = None, for_extraction: bool = False, + visible_reply: bool = False, ) -> dict[str, Any]: use_tools = bool(tools) and self.tools_enabled and not for_extraction kwargs: dict[str, Any] = { @@ -198,7 +199,7 @@ class LLMClient: reasoning = str(value) break - if not content and reasoning: + if not content and reasoning and not visible_reply: content = reasoning result: dict[str, Any] = { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 66ebf2c..0299b26 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -228,22 +228,17 @@ export const api = { }); if (!response.ok || !response.body) { - throw new Error("Failed to send message"); + const detail = await response.text().catch(() => ""); + throw new Error(detail || `Ошибка отправки (${response.status})`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const parts = buffer.split("\n\n"); - buffer = parts.pop() ?? ""; - + const flushParts = function* (parts: string[]) { for (const part of parts) { + if (!part.trim()) continue; const lines = part.split("\n"); let event = "message"; let data = ""; @@ -257,6 +252,24 @@ export const api = { yield { event, data: JSON.parse(data) }; } } + }; + + while (true) { + const { done, value } = await reader.read(); + if (value) { + buffer += decoder.decode(value, { stream: !done }); + } + + const parts = buffer.split("\n\n"); + buffer = parts.pop() ?? ""; + yield* flushParts(parts); + + if (done) { + if (buffer.trim()) { + yield* flushParts([buffer]); + } + break; + } } }, diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index 2b8547b..9edaa75 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -190,7 +190,6 @@ export default function Chat() { setStreaming(""); setLiveNotices([]); setChatError(null); - setLoading(false); if (assistantText.trim()) { setMessages((prev) => [ ...prev, @@ -202,8 +201,8 @@ export default function Chat() { }, ]); } - void loadMessages(activeId); - void loadSessions(); + await loadMessages(activeId); + await loadSessions(); } if (chunk.event === "error") { throw new Error(chunk.data.message); @@ -214,6 +213,9 @@ export default function Chat() { const message = err instanceof Error ? err.message : "Ошибка чата"; setChatError(message); setStreaming(""); + if (activeId) { + await loadMessages(activeId); + } } finally { setLoading(false); }