diff --git a/backend/app/api/routes/chat.py b/backend/app/api/routes/chat.py index 3587589..7637590 100644 --- a/backend/app/api/routes/chat.py +++ b/backend/app/api/routes/chat.py @@ -52,4 +52,12 @@ async def send_message( async for chunk in service.stream_response(session_id, payload.content): yield chunk - return StreamingResponse(event_stream(), media_type="text/event-stream") + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py index 4cea54b..478e038 100644 --- a/backend/app/chat/service.py +++ b/backend/app/chat/service.py @@ -37,6 +37,18 @@ MAX_HISTORY_MESSAGES = 40 logger = logging.getLogger(__name__) +def _build_messages_for_session(session_id: int) -> list[dict[str, Any]]: + db = SessionLocal() + try: + service = ChatService(db) + session = service.get_session(session_id) + if not session: + return [] + return service._build_messages(session) + finally: + db.close() + + async def _extract_memory_background( session_id: int, user_text: str, @@ -166,7 +178,12 @@ class ChatService: return self._save_message(session_id, "user", user_text) - messages = self._build_messages(session) + yield self._sse("status", {"phase": "preparing"}) + messages = await asyncio.to_thread(_build_messages_for_session, session_id) + if not messages: + yield self._sse("error", {"message": "Session not found"}) + return + yield self._sse("status", {"phase": "generating"}) streamed_reply_parts: list[str] = [] for _ in range(MAX_TOOL_ROUNDS): diff --git a/backend/app/homelab/openmeteo.py b/backend/app/homelab/openmeteo.py index 22d0370..6c35755 100644 --- a/backend/app/homelab/openmeteo.py +++ b/backend/app/homelab/openmeteo.py @@ -137,6 +137,17 @@ def format_weather_snapshot(data: dict[str, Any] | None = None) -> str: f"(ощущается {cur.get('apparent_temperature_c')}°C), " f"{cur.get('conditions')}, ветер {cur.get('wind_speed_kmh')} км/ч." ) - lines.append(client.rain_summary(hours_ahead=6)) + hourly = snapshot.get("hourly") or [] + rainy_hours = [] + for hour in hourly: + prob = hour.get("precipitation_probability") + precip = hour.get("precipitation_mm") or 0 + if (prob is not None and prob >= 40) or precip > 0: + time_str = (hour.get("time") or "")[11:16] + rainy_hours.append(f"{time_str} ({prob}% вероятность, {precip} мм)") + if rainy_hours: + lines.append("Ожидаются осадки: " + ", ".join(rainy_hours[:6])) + else: + lines.append("Существенных осадков в ближайшие часы не ожидается.") lines.append("Вопросы «что на улице» / «будет ли дождь» — get_weather.") return "\n".join(lines) diff --git a/backend/app/projects/context.py b/backend/app/projects/context.py index 283a4cc..796237d 100644 --- a/backend/app/projects/context.py +++ b/backend/app/projects/context.py @@ -1,3 +1,4 @@ +import time from typing import Any from sqlalchemy.orm import Session @@ -8,9 +9,28 @@ from app.projects.service import ProjectService MAX_PROJECTS_IN_CONTEXT = 20 MAX_OPEN_PER_PROJECT = 8 +PROJECTS_CACHE_SEC = 120 + +_cache: dict[str, Any] = {"data": None, "expires_at": 0.0} -def get_projects_snapshot(db: Session) -> dict[str, Any]: +def invalidate_projects_snapshot_cache() -> None: + _cache["data"] = None + _cache["expires_at"] = 0.0 + + +def get_projects_snapshot(db: Session, *, force: bool = False) -> dict[str, Any]: + now = time.time() + if not force and _cache["data"] is not None and now < _cache["expires_at"]: + return _cache["data"] + + snapshot = _fetch_projects_snapshot(db) + _cache["data"] = snapshot + _cache["expires_at"] = now + PROJECTS_CACHE_SEC + return snapshot + + +def _fetch_projects_snapshot(db: Session) -> dict[str, Any]: settings = get_settings() service = ProjectService(db) diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index 80ba259..82c55da 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -643,7 +643,10 @@ async def execute_tool( elif name == "get_pomodoro_history": result = pomodoro.history(limit=arguments.get("limit", 10)) elif name == "sync_taiga_projects": + from app.projects.context import invalidate_projects_snapshot_cache + result = projects.sync_taiga_projects() + invalidate_projects_snapshot_cache() elif name == "list_taiga_projects": result = projects.list_projects() elif name == "list_taiga_tasks": diff --git a/frontend/src/context/PomodoroContext.tsx b/frontend/src/context/PomodoroContext.tsx index 83692e6..0854a93 100644 --- a/frontend/src/context/PomodoroContext.tsx +++ b/frontend/src/context/PomodoroContext.tsx @@ -18,7 +18,8 @@ interface PomodoroContextValue { const PomodoroContext = createContext(null); const POLL_ACTIVE_MS = 1000; -const POLL_IDLE_MS = 30000; +const POLL_IDLE_MS = 60000; +const POLL_HIDDEN_MS = 120000; function isTimerActive(status: PomodoroStatus | null): boolean { return status?.status === "running" || status?.status === "paused"; @@ -28,16 +29,28 @@ export function PomodoroProvider({ children }: { children: ReactNode }) { const [status, setStatus] = useState(null); const [error, setError] = useState(null); const statusRef = useRef(null); + const refreshInFlight = useRef | null>(null); const refresh = useCallback(async () => { - try { - const data = await api.pomodoroStatus(); - statusRef.current = data; - setStatus(data); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Ошибка загрузки таймера"); + if (refreshInFlight.current) { + return refreshInFlight.current; } + + const task = (async () => { + try { + const data = await api.pomodoroStatus(); + statusRef.current = data; + setStatus(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка загрузки таймера"); + } finally { + refreshInFlight.current = null; + } + })(); + + refreshInFlight.current = task; + return task; }, []); useEffect(() => { @@ -46,19 +59,34 @@ export function PomodoroProvider({ children }: { children: ReactNode }) { let cancelled = false; let timeoutId: ReturnType; - const schedule = () => { - const delay = isTimerActive(statusRef.current) ? POLL_ACTIVE_MS : POLL_IDLE_MS; - timeoutId = setTimeout(async () => { - if (cancelled) return; - await refresh().catch(console.error); - schedule(); - }, delay); + const pollDelay = () => { + if (document.hidden) return POLL_HIDDEN_MS; + return isTimerActive(statusRef.current) ? POLL_ACTIVE_MS : POLL_IDLE_MS; }; + const schedule = () => { + timeoutId = setTimeout(async () => { + if (cancelled) return; + if (!document.hidden) { + await refresh().catch(console.error); + } + schedule(); + }, pollDelay()); + }; + + const onVisibilityChange = () => { + if (!document.hidden) { + refresh().catch(console.error); + } + }; + + document.addEventListener("visibilitychange", onVisibilityChange); schedule(); + return () => { cancelled = true; clearTimeout(timeoutId); + document.removeEventListener("visibilitychange", onVisibilityChange); }; }, [refresh]); diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index aab80fd..ef7e985 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -38,6 +38,9 @@ export default function Chat() { const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [streaming, setStreaming] = useState(""); + const [pendingPhase, setPendingPhase] = useState<"thinking" | "preparing" | "generating">( + "thinking", + ); const [liveNotices, setLiveNotices] = useState([]); const bottomRef = useRef(null); const { status: pomodoroStatus, refresh: refreshPomodoro } = usePomodoro(); @@ -71,7 +74,14 @@ export default function Chat() { }, [messages, streaming, liveNotices, loading]); const waitingForStream = loading && !streaming; - const pendingLabel = liveNotices.length > 0 ? "Обрабатываю…" : "Думаю…"; + const pendingLabel = + liveNotices.length > 0 + ? "Обрабатываю…" + : pendingPhase === "preparing" + ? "Собираю контекст…" + : pendingPhase === "generating" + ? "Генерирую ответ…" + : "Думаю…"; useEffect(() => { const seq = pomodoroStatus?.cycle?.chat_notify_seq ?? 0; @@ -111,6 +121,7 @@ export default function Chat() { setInput(""); setLoading(true); setStreaming(""); + setPendingPhase("thinking"); setLiveNotices([]); const tempUser: ChatMessage = { @@ -122,9 +133,19 @@ export default function Chat() { setMessages((prev) => [...prev, tempUser]); try { + let assistantText = ""; for await (const chunk of api.sendMessage(activeId, text)) { + if (chunk.event === "status") { + if (chunk.data.phase === "preparing") { + setPendingPhase("preparing"); + } + if (chunk.data.phase === "generating") { + setPendingPhase("generating"); + } + } if (chunk.event === "token") { - setStreaming((prev) => prev + chunk.data.content); + assistantText += chunk.data.content; + setStreaming(assistantText); } if (chunk.event === "notice") { setLiveNotices((prev) => [...prev, chunk.data.content]); @@ -136,10 +157,22 @@ export default function Chat() { refreshPomodoro(); } if (chunk.event === "done") { - await loadMessages(activeId); - await loadSessions(); setStreaming(""); setLiveNotices([]); + setLoading(false); + if (assistantText.trim()) { + setMessages((prev) => [ + ...prev, + { + id: Date.now(), + role: "assistant", + content: assistantText, + created_at: new Date().toISOString(), + }, + ]); + } + void loadMessages(activeId); + void loadSessions(); } if (chunk.event === "error") { throw new Error(chunk.data.message);