diff --git a/app.py b/app.py index 8eb9de6..7cd251c 100644 --- a/app.py +++ b/app.py @@ -5,7 +5,7 @@ import math import os import threading import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from xml.etree import ElementTree as ET @@ -19,7 +19,7 @@ from sqlalchemy.orm import Session from starlette.middleware.sessions import SessionMiddleware from auth import require_user, router as auth_router -from db import Insight, Ride, User, create_tables, engine +from db import ChatMessage, Goal, Insight, Ride, User, create_tables, engine from storage import download_gpx, upload_gpx logging.basicConfig( @@ -454,6 +454,178 @@ def generate_all_insights(user: User = Depends(require_user), rhr: int = Query(6 return StreamingResponse(sse_stream(), media_type="text/event-stream") +# --------------------------------------------------------------------------- +# AI Trainer Chat +# --------------------------------------------------------------------------- + + +def _build_trainer_context(user_id: str, new_message: str) -> list[dict]: + rides_map = get_user_rides_cached(user_id) + cutoff = datetime.now(timezone.utc) - timedelta(days=30) + recent_rides = [] + for r in rides_map.values(): + if r.get("start_time") and r["has_gps"] and r["distance_km"] > 0: + try: + rt = datetime.fromisoformat(r["start_time"]) + if rt.tzinfo is None: + rt = rt.replace(tzinfo=timezone.utc) + if rt >= cutoff: + recent_rides.append(r) + except (ValueError, TypeError): + pass + + if recent_rides: + total_km = sum(r["distance_km"] for r in recent_rides) + total_hours = sum(r["duration_s"] for r in recent_rides) / 3600 + speed_list = [r["avg_speed_kmh"] for r in recent_rides if r["avg_speed_kmh"]] + avg_speed = sum(speed_list) / len(speed_list) if speed_list else 0 + hr_list = [r["avg_hr"] for r in recent_rides if r["avg_hr"]] + avg_hr = round(sum(hr_list) / len(hr_list)) if hr_list else None + max_hr = max((r["max_hr"] or 0 for r in recent_rides), default=0) or None + rides_block = ( + f"Past 30 days: {len(recent_rides)} ride{'s' if len(recent_rides) != 1 else ''}, " + f"{total_km:.1f} km total, {total_hours:.1f} hours. " + f"Avg speed {avg_speed:.1f} km/h." + + (f" HR: avg {avg_hr} bpm, max {max_hr} bpm." if avg_hr else "") + ) + else: + rides_block = "No rides recorded in the past 30 days." + + with Session(engine) as s: + active_goals = ( + s.query(Goal) + .filter_by(user_id=user_id, is_active=True) + .order_by(Goal.created_at.asc()) + .all() + ) + goals_block = ( + "\n".join( + f"- {g.title}" + (f": {g.description}" if g.description else "") + for g in active_goals + ) + if active_goals + else "No goals set yet. Ask the athlete what they're training for." + ) + + system_prompt = f"""You are a personal cycling coach having an ongoing conversation with your athlete. Be supportive, specific, and data-driven. Keep responses concise (2–4 sentences unless the athlete asks for detail). Reference their actual numbers when relevant. Do not use headers or bullet points unless explicitly asked. + +## Athlete's Current Goals +{goals_block} + +## Recent Ride Activity (last 30 days) +{rides_block}""" + + with Session(engine) as s: + history_rows = ( + s.query(ChatMessage) + .filter_by(user_id=user_id) + .order_by(ChatMessage.created_at.desc()) + .limit(21) + .all() + ) + # Drop index 0 (the just-saved user message), reverse to chronological order + history_rows = list(reversed(history_rows[1:])) + + messages: list[dict] = [{"role": "system", "content": system_prompt}] + for msg in history_rows: + messages.append({"role": msg.role, "content": msg.content}) + messages.append({"role": "user", "content": new_message}) + return messages + + +@app.get("/api/chat/history") +def get_chat_history(user: User = Depends(require_user)): + with Session(engine) as s: + rows = ( + s.query(ChatMessage) + .filter_by(user_id=user.id) + .order_by(ChatMessage.created_at.desc()) + .limit(50) + .all() + ) + return [r.to_dict() for r in reversed(rows)] + + +@app.post("/api/chat") +async def post_chat(request: Request, user: User = Depends(require_user)): + body = await request.json() + message = (body.get("message") or "").strip() + if not message: + raise HTTPException(status_code=400, detail="message required") + + with Session(engine) as s: + s.add(ChatMessage(user_id=user.id, role="user", content=message)) + s.commit() + + prompt_messages = _build_trainer_context(user.id, message) + client = _llm_client() + log.info("POST /chat user=%s", user.id) + + def stream_response(): + accumulated = [] + try: + stream = client.chat.completions.create( + model=MODEL_NAME, + messages=prompt_messages, + stream=True, + max_tokens=800, + extra_body={"chat_template_kwargs": {"enable_thinking": False}}, + ) + for chunk in stream: + d = chunk.choices[0].delta.model_dump() + delta = d.get("content") or d.get("reasoning_content") + if delta: + accumulated.append(delta) + yield delta + with Session(engine) as s: + s.add(ChatMessage(user_id=user.id, role="assistant", content="".join(accumulated))) + s.commit() + except Exception as exc: + log.exception("Error streaming chat: %s", exc) + yield f"\n\n[Error: {exc}]" + + return StreamingResponse(stream_response(), media_type="text/plain") + + +@app.get("/api/goals") +def get_goals(user: User = Depends(require_user)): + with Session(engine) as s: + rows = ( + s.query(Goal) + .filter_by(user_id=user.id, is_active=True) + .order_by(Goal.created_at.desc()) + .all() + ) + return [r.to_dict() for r in rows] + + +@app.post("/api/goals") +async def create_goal(request: Request, user: User = Depends(require_user)): + body = await request.json() + title = (body.get("title") or "").strip() + if not title: + raise HTTPException(status_code=400, detail="title required") + description = (body.get("description") or "").strip() or None + with Session(engine) as s: + goal = Goal(user_id=user.id, title=title, description=description) + s.add(goal) + s.commit() + s.refresh(goal) + result = goal.to_dict() + return result + + +@app.delete("/api/goals/{goal_id}") +def delete_goal(goal_id: str, user: User = Depends(require_user)): + with Session(engine) as s: + goal = s.query(Goal).filter_by(id=goal_id, user_id=user.id).first() + if not goal: + raise HTTPException(status_code=404, detail="Goal not found") + goal.is_active = False + s.commit() + return {"ok": True} + + # --------------------------------------------------------------------------- # Garmin Connect sync # --------------------------------------------------------------------------- diff --git a/db.py b/db.py index e0d6072..be5dbbe 100644 --- a/db.py +++ b/db.py @@ -39,6 +39,8 @@ class User(Base): rides = relationship("Ride", back_populates="user", cascade="all, delete-orphan") insights = relationship("Insight", back_populates="user", cascade="all, delete-orphan") + chat_messages = relationship("ChatMessage", back_populates="user", cascade="all, delete-orphan") + goals = relationship("Goal", back_populates="user", cascade="all, delete-orphan") class Ride(Base): @@ -101,6 +103,48 @@ class Insight(Base): user = relationship("User", back_populates="insights") +class ChatMessage(Base): + __tablename__ = "chat_messages" + + id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex) + user_id = Column(String, ForeignKey("users.id"), nullable=False) + role = Column(String, nullable=False) + content = Column(Text, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + user = relationship("User", back_populates="chat_messages") + + def to_dict(self) -> dict: + return { + "id": self.id, + "role": self.role, + "content": self.content, + "created_at": self.created_at.isoformat(), + } + + +class Goal(Base): + __tablename__ = "goals" + + id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex) + user_id = Column(String, ForeignKey("users.id"), nullable=False) + title = Column(String, nullable=False) + description = Column(Text, nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + user = relationship("User", back_populates="goals") + + def to_dict(self) -> dict: + return { + "id": self.id, + "title": self.title, + "description": self.description, + "is_active": self.is_active, + "created_at": self.created_at.isoformat(), + } + + def create_tables() -> None: Base.metadata.create_all(engine) diff --git a/static/index.html b/static/index.html index 4d9e30a..f8224ae 100644 --- a/static/index.html +++ b/static/index.html @@ -144,10 +144,57 @@ @keyframes blink { 50% { opacity: 0; } } .no-ride-msg { color: var(--muted); font-size: 12px; text-align: center; padding: 24px 16px; } + /* Trainer panel */ + #trainer-panel { display: none; flex: 1; flex-direction: column; background: var(--surface); overflow: hidden; } + #trainer-panel.trainer-active { display: flex; } + #goals-section { border-bottom: 1px solid var(--border); padding: 12px 16px; flex-shrink: 0; max-height: 220px; overflow-y: auto; } + #goals-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } + #goals-header .section-title { margin-bottom: 0; } + #add-goal-btn { font-size: 20px; background: none; border: none; color: var(--accent); cursor: pointer; line-height: 1; padding: 0 2px; } + .goal-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); } + .goal-item:last-child { border-bottom: none; } + .goal-text { flex: 1; } + .goal-title { font-size: 12px; font-weight: 500; color: var(--text); } + .goal-desc { font-size: 11px; color: var(--muted); margin-top: 2px; } + .goal-delete { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 16px; flex-shrink: 0; padding: 0; transition: color 0.15s; } + .goal-delete:hover { color: var(--red); } + #no-goals-msg { font-size: 12px; color: var(--muted); font-style: italic; } + #goal-add-form { display: none; flex-direction: column; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); } + #goal-add-form.open { display: flex; } + #goal-title-input, #goal-desc-input { background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 12px; padding: 6px 8px; width: 100%; font-family: inherit; } + #goal-title-input:focus, #goal-desc-input:focus { outline: none; border-color: var(--accent); } + #goal-desc-input { resize: none; height: 48px; } + .goal-form-actions { display: flex; gap: 6px; } + .goal-form-actions button { flex: 1; padding: 6px; border-radius: 6px; font-size: 11px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); transition: all 0.15s; } + #goal-save-btn { background: var(--accent); color: #fff; border-color: var(--accent); } + #goal-save-btn:hover { opacity: 0.85; } + #goal-cancel-btn { background: var(--surface2); color: var(--muted); } + #goal-cancel-btn:hover { color: var(--text); } + #chat-messages { flex: 1; overflow-y: auto; padding: 12px 16px; display: flex; flex-direction: column; gap: 10px; } + #chat-messages::-webkit-scrollbar { width: 4px; } + #chat-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + .chat-msg { max-width: 85%; } + .chat-msg.user { align-self: flex-end; } + .chat-msg.assistant { align-self: flex-start; } + .chat-bubble { padding: 8px 12px; border-radius: 12px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; } + .chat-msg.user .chat-bubble { background: var(--accent); color: #fff; border-bottom-right-radius: 3px; } + .chat-msg.assistant .chat-bubble { background: var(--surface2); color: var(--text); border-bottom-left-radius: 3px; } + .chat-time { font-size: 10px; color: var(--muted); margin-top: 3px; } + .chat-msg.user .chat-time { text-align: right; } + #chat-input-bar { border-top: 1px solid var(--border); padding: 10px 12px; display: flex; gap: 8px; align-items: flex-end; background: var(--surface); flex-shrink: 0; } + #chat-input { flex: 1; background: var(--surface2); border: 1px solid var(--border); border-radius: 18px; color: var(--text); font-size: 13px; padding: 8px 14px; resize: none; font-family: inherit; line-height: 1.4; max-height: 100px; overflow-y: auto; } + #chat-input:focus { outline: none; border-color: var(--accent); } + #chat-send-btn { background: var(--accent); border: none; border-radius: 50%; width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; transition: opacity 0.15s; } + #chat-send-btn:hover { opacity: 0.85; } + #chat-send-btn:disabled { opacity: 0.4; cursor: not-allowed; } + #chat-send-btn svg { width: 16px; height: 16px; stroke: #fff; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } + #trainer-sidebar-btn { margin-top: 4px; width: 100%; padding: 6px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); font-size: 11px; cursor: pointer; transition: all 0.15s; } + #trainer-sidebar-btn:hover { border-color: var(--accent); color: var(--text); } + /* Mobile */ #mobile-nav { display: none; } @media (max-width: 768px) { - #sidebar, #main, #details { + #sidebar, #main, #details, #trainer-panel { position: fixed; top: 0; left: 0; right: 0; bottom: calc(48px + env(safe-area-inset-bottom)); @@ -155,6 +202,7 @@ display: none; } #sidebar.mobile-active, #main.mobile-active, #details.mobile-active { display: flex; } + #trainer-panel.mobile-active { display: flex; } #sidebar { border-right: none; } #details { border-left: none; } #mobile-nav { @@ -212,6 +260,7 @@
+