diff --git a/backend/app/chat/notice_inbox.py b/backend/app/chat/notice_inbox.py new file mode 100644 index 0000000..5676c8b --- /dev/null +++ b/backend/app/chat/notice_inbox.py @@ -0,0 +1,25 @@ +"""Инжект системных оповещений в чат без role=assistant (не ломает LLM-историю).""" + +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import ChatSession, Message + + +def post_notice_to_latest_chat(content: str) -> int | None: + """Сохраняет notice в последний активный чат. Возвращает session_id.""" + db = SessionLocal() + try: + session = db.scalar( + select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1) + ) + if not session: + session = ChatSession(title="Уведомления") + db.add(session) + db.commit() + db.refresh(session) + db.add(Message(session_id=session.id, role="notice", content=content)) + db.commit() + return session.id + finally: + db.close() diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py index 478e038..2950001 100644 --- a/backend/app/chat/service.py +++ b/backend/app/chat/service.py @@ -234,6 +234,7 @@ class ChatService: reasoning_json=reasoning_json, ) + round_notices: list[str] = [] for tool_call in tool_calls: fn = tool_call["function"] args = LLMClient.parse_tool_arguments(fn.get("arguments", "")) @@ -251,7 +252,7 @@ class ChatService: notice = format_tool_notice(fn["name"], result) if notice: self._save_message(session_id, "notice", notice) - yield self._sse("notice", {"content": notice}) + round_notices.append(notice) if fn["name"] in POMODORO_TOOL_NAMES: yield self._sse( @@ -259,6 +260,9 @@ class ChatService: {"name": fn["name"], "result": json.loads(result)}, ) + for notice in round_notices: + yield self._sse("notice", {"content": notice}) + continue final_content = "".join(content_parts).strip() diff --git a/backend/app/homelab/notices.py b/backend/app/homelab/notices.py index 5f0a494..35c96d6 100644 --- a/backend/app/homelab/notices.py +++ b/backend/app/homelab/notices.py @@ -1,21 +1,5 @@ -from sqlalchemy import select - -from app.db.base import SessionLocal -from app.db.models import ChatSession, Message +from app.chat.notice_inbox import post_notice_to_latest_chat def post_chat_notice(content: str) -> None: - db = SessionLocal() - try: - session = db.scalar( - select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1) - ) - if not session: - session = ChatSession(title="Уведомления") - db.add(session) - db.commit() - db.refresh(session) - db.add(Message(session_id=session.id, role="notice", content=content)) - db.commit() - finally: - db.close() + post_notice_to_latest_chat(content) diff --git a/backend/app/homelab/watcher.py b/backend/app/homelab/watcher.py index fba71cc..78b66a7 100644 --- a/backend/app/homelab/watcher.py +++ b/backend/app/homelab/watcher.py @@ -4,6 +4,8 @@ import random from datetime import datetime from zoneinfo import ZoneInfo +import httpx + from app.config import get_settings from app.db.base import SessionLocal from app.homelab.comfyui import ComfyUIClient @@ -76,6 +78,15 @@ async def _tick_netdata() -> None: db.close() +async def _comfyui_reachable(base_url: str) -> bool: + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(3.0, connect=2.0)) as client: + response = await client.get(f"{base_url.rstrip('/')}/system_stats") + return response.status_code < 500 + except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError): + return False + + async def _tick_rofl() -> None: settings = get_settings() if not settings.comfyui_enabled or not settings.comfyui_rofl_enabled: @@ -114,8 +125,18 @@ async def _tick_rofl() -> None: return client = ComfyUIClient() + if not await _comfyui_reachable(client.base_url): + return + prompt = client.random_rofl_prompt() - result = await client.generate_image(prompt) + try: + result = await asyncio.wait_for( + client.generate_image(prompt), + timeout=settings.comfyui_timeout_sec + 15, + ) + except (asyncio.TimeoutError, httpx.TimeoutException, httpx.ConnectError) as exc: + logger.warning("Rofl image skipped (ComfyUI): %s", exc) + return if not result.get("ok"): logger.warning("Rofl image failed: %s", result.get("error")) return diff --git a/backend/app/llm/client.py b/backend/app/llm/client.py index 1b8dac2..bcfb36d 100644 --- a/backend/app/llm/client.py +++ b/backend/app/llm/client.py @@ -44,6 +44,21 @@ class LLMClient: return "".join(parts), details + @staticmethod + def _normalize_reasoning_details(details: Any) -> list[Any] | None: + if not details: + return None + items = details if isinstance(details, list) else [details] + normalized: list[Any] = [] + for item in items: + if hasattr(item, "model_dump"): + normalized.append(item.model_dump()) + elif isinstance(item, dict): + normalized.append(item) + else: + normalized.append(item) + return normalized or None + @staticmethod def attach_reasoning_to_message( message: dict[str, Any], @@ -54,8 +69,9 @@ class LLMClient: if reasoning: message["reasoning"] = reasoning message["reasoning_content"] = reasoning - if reasoning_details: - message["reasoning_details"] = reasoning_details + normalized = LLMClient._normalize_reasoning_details(reasoning_details) + if normalized: + message["reasoning_details"] = normalized return message async def stream_chat( @@ -126,14 +142,21 @@ class LLMClient: if choice.finish_reason: reasoning = "".join(reasoning_parts) - if reasoning or reasoning_details: + normalized_details = self._normalize_reasoning_details(reasoning_details) + if reasoning or normalized_details: yield { "type": "reasoning", "reasoning": reasoning, - "reasoning_details": reasoning_details or None, + "reasoning_details": normalized_details, } if tool_calls: yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())} + logger.debug( + "LLM stream done: finish_reason=%s tool_calls=%d reasoning_len=%d", + choice.finish_reason, + len(tool_calls), + len(reasoning), + ) yield {"type": "done", "finish_reason": choice.finish_reason} except Exception as exc: logger.exception("LLM stream read failed: %s", exc) diff --git a/backend/app/pomodoro/completion.py b/backend/app/pomodoro/completion.py index a5a0fe9..b7d3891 100644 --- a/backend/app/pomodoro/completion.py +++ b/backend/app/pomodoro/completion.py @@ -1,13 +1,15 @@ -from sqlalchemy import select +import logging + from sqlalchemy.orm import Session -from app.character.service import CharacterService +from app.chat.notice_inbox import post_notice_to_latest_chat from app.chat.notices import format_phase_completed_notice -from app.db.models import ChatSession, Message, PomodoroSession -from app.llm.client import LLMClient +from app.db.models import PomodoroSession from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager from app.pomodoro.service import PomodoroService +logger = logging.getLogger(__name__) + PHASE_LABELS = { PHASE_WORK: "работа", PHASE_SHORT_BREAK: "короткий перерыв", @@ -20,48 +22,6 @@ class PomodoroCompletionHandler: self.db = db self.pomodoro = PomodoroService(db) self.cycle = CycleManager(db) - self.llm = LLMClient() - self.character = CharacterService() - - def _latest_chat_session_id(self) -> int | None: - stmt = select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1) - session = self.db.scalar(stmt) - return session.id if session else None - - def _save_chat_message(self, session_id: int, role: str, content: str) -> None: - self.db.add(Message(session_id=session_id, role=role, content=content)) - chat = self.db.get(ChatSession, session_id) - if chat: - chat.updated_at = chat.updated_at # trigger onupdate - self.db.commit() - - async def _generate_llm_comment( - self, - session: PomodoroSession, - next_phase: str | None, - ) -> str: - cycle = self.cycle.to_dict() - phase_label = PHASE_LABELS.get(session.phase, session.phase) - next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен" - work_done = cycle["completed_work_sessions"] - if session.phase == PHASE_WORK: - work_done += 1 - - system = self.character.get_system_prompt() - user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась. -Задача: {session.task_note or 'без описания'} -Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ. -Следующая фаза: {next_label}. - -Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown.""" - - result = await self.llm.complete( - [ - {"role": "system", "content": system}, - {"role": "user", "content": user_prompt}, - ] - ) - return (result.get("content") or "").strip() or "Фаза завершена! Отличная работа." def _resolve_next_phase(self, session: PomodoroSession) -> str | None: phase = session.phase @@ -83,19 +43,10 @@ class PomodoroCompletionHandler: next_phase = self._resolve_next_phase(session) notice = format_phase_completed_notice(session, next_phase) - chat_id = self._latest_chat_session_id() - if not chat_id: - chat = ChatSession(title="Помидоро") - self.db.add(chat) - self.db.commit() - self.db.refresh(chat) - chat_id = chat.id - - self._save_chat_message(chat_id, "notice", notice) - - comment = await self._generate_llm_comment(session, next_phase) - self._save_chat_message(chat_id, "assistant", comment) + # Только notice — role=assistant ломает tool/reasoning цепочки OpenRouter. + post_notice_to_latest_chat(notice) self.cycle.bump_notify_seq() self.pomodoro.mark_notified(session) self.pomodoro.advance_after_completion(session) + logger.info("Pomodoro phase completed notice posted (phase=%s)", session.phase) diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index 7a264a9..f2f53a3 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -114,11 +114,12 @@ export default function Chat() { if (seq > lastNotifySeq) { setLastNotifySeq(seq); refreshPomodoro().catch(console.error); - if (activeId) { + // Не перезагружать историю во время стрима — ломает UI и сбивает ответ. + if (activeId && !loading) { loadMessages(activeId).catch(console.error); } } - }, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro]); + }, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro, loading]); const handleNewChat = async () => { const session = await api.createSession();