Jacob Hinkle 5584022a23 Single-container AI coach with agent API endpoints and UI polish
- Merge opencode-serve into the web container via entrypoint script
- Add /api/agent/* JSON endpoints for workouts, sets, checkins
- Rewrite fitness-trainer.md to use API instead of markdown files
- Pass recent workouts and check-ins as chat context to the coach
- Show current training phase on dashboard
- Clarify check-ins as morning check-ins (calories/steps = yesterday)
- Add NixOS deployment section to README
- Make all check-in fields explicitly optional in UI
2026-06-29 10:50:01 -04:00

134 lines
4.5 KiB
Python

from datetime import datetime, timezone
import uuid
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select, desc
from app.models.base import async_session
from app.models.user import User
from app.models.chat import ChatMessage
from app.models.workout import Workout
from app.models.checkin import Checkin
from app.auth import get_current_user
from app.services.opencode_proxy import query_opencode
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/chat", response_class=HTMLResponse)
async def chat_page(request: Request, user: User = Depends(get_current_user)):
session_id = request.cookies.get("chat_session_id")
if not session_id:
session_id = str(uuid.uuid4())
async with async_session() as session:
result = await session.execute(
select(ChatMessage)
.where(
ChatMessage.user_id == user.id,
ChatMessage.session_id == session_id,
)
.order_by(ChatMessage.created_at)
)
messages = result.scalars().all()
resp = templates.TemplateResponse(request, "chat.html", {
"user": user,
"messages": messages,
"session_id": session_id,
})
resp.set_cookie(key="chat_session_id", value=session_id, httponly=True, max_age=86400 * 30)
return resp
@router.post("/chat")
async def chat_send(
request: Request,
user: User = Depends(get_current_user),
message: str = Form(),
):
session_id = request.cookies.get("chat_session_id") or str(uuid.uuid4())
async with async_session() as session:
result = await session.execute(
select(Workout)
.where(Workout.user_id == user.id)
.order_by(desc(Workout.date))
.limit(5)
)
recent_workouts = result.scalars().all()
result = await session.execute(
select(Checkin)
.where(Checkin.user_id == user.id)
.order_by(desc(Checkin.date))
.limit(5)
)
recent_checkins = result.scalars().all()
workout_lines = []
for w in recent_workouts:
workout_lines.append(f" {w.date}{w.name} ({w.status})")
checkin_lines = []
for c in recent_checkins:
parts = []
if c.feeling:
parts.append(f"feeling={c.feeling}")
if c.weight_lb:
parts.append(f"weight={c.weight_lb}lb")
if c.calories:
parts.append(f"cal(yesterday)={c.calories}")
if c.steps:
parts.append(f"steps(yesterday)={c.steps}")
if c.sleep_hours:
parts.append(f"sleep={c.sleep_hours}h")
checkin_lines.append(f" {c.date}{' | '.join(parts)}")
user_context = (
f"Username: {user.username}. "
f"Weight: {user.weight_lb} lb. "
f"Goals: {user.goals or 'Not specified'}. "
f"Equipment: {user.equipment or 'Not specified'}. "
f"Medical: {user.medical_notes or 'None'}. "
f"Calorie goal: {user.calorie_goal or 'Not set'}. "
f"Step goal: {user.step_goal or 'Not set'}. "
)
if recent_workouts:
user_context += "Recent workouts:\n" + "\n".join(workout_lines) + ". "
if recent_checkins:
user_context += "Recent check-ins:\n" + "\n".join(checkin_lines) + ". "
async def stream():
async with async_session() as session:
user_msg = ChatMessage(
user_id=user.id,
session_id=session_id,
role="user",
content=message,
created_at=datetime.now(timezone.utc).isoformat(),
)
session.add(user_msg)
await session.commit()
assistant_content = ""
async for chunk in query_opencode(message, session_id, user_context):
assistant_content += chunk
yield f"data: {chunk}\n\n"
async with async_session() as session:
assistant_msg = ChatMessage(
user_id=user.id,
session_id=session_id,
role="assistant",
content=assistant_content,
created_at=datetime.now(timezone.utc).isoformat(),
)
session.add(assistant_msg)
await session.commit()
resp = StreamingResponse(stream(), media_type="text/event-stream")
resp.set_cookie(key="chat_session_id", value=session_id, httponly=True, max_age=86400 * 30)
return resp