Add opencode serve requirement to README
This commit is contained in:
parent
15a80d1fd2
commit
9584744d97
@ -9,7 +9,7 @@ Track workouts, log daily check-ins, explore exercise history, and chat with an
|
||||
- **Workouts** — Plan and log workouts with set-level detail (reps, weight, RPE)
|
||||
- **Exercises** — Catalog with body-part filtering and history
|
||||
- **Check-ins** — Daily weight, calories, steps, sleep tracking
|
||||
- **AI Coach** — Chat interface backed by opencode (Big Pickle model, free)
|
||||
- **AI Coach** — Persistent chat sidebar (desktop) / full-page chat (mobile) powered by opencode (Big Pickle model, free). Runs as a separate process.
|
||||
- **Multi-user** — Login-based, each user has independent data
|
||||
- **Calendar view** — See your training history at a glance
|
||||
- **NixOS-ready** — Docker-based deploy with provided NixOS module
|
||||
@ -24,12 +24,17 @@ uv sync
|
||||
uv run python scripts/schema.py
|
||||
uv run python scripts/seed.py
|
||||
|
||||
# Start the AI coach (in a separate terminal)
|
||||
opencode serve --port 4096
|
||||
|
||||
# Start the dev server
|
||||
uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Open http://localhost:8000, register a user, and you're ready.
|
||||
|
||||
> **Note:** Chat requires `opencode serve` running on port 4096. If `opencode` is not in PATH, use `~/.opencode/bin/opencode serve --port 4096`.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
from app.models.base import Base, engine, async_session
|
||||
from app.models.user import User
|
||||
from app.models.auth import Session
|
||||
from app.models.workout import Phase, Workout, WorkoutSet
|
||||
from app.models.workout import Phase, Workout, WorkoutSet, WorkoutSnapshot
|
||||
from app.models.exercise import Exercise
|
||||
from app.models.checkin import Checkin
|
||||
from app.models.chat import ChatMessage
|
||||
from app.models.measurement import MeasurementType, Measurement
|
||||
|
||||
24
app/models/measurement.py
Normal file
24
app/models/measurement.py
Normal file
@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Text, ForeignKey
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class MeasurementType(Base):
|
||||
__tablename__ = "measurement_types"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(100), nullable=False, unique=True)
|
||||
description = Column(Text)
|
||||
unit = Column(String(50))
|
||||
instructions = Column(Text)
|
||||
|
||||
|
||||
class Measurement(Base):
|
||||
__tablename__ = "measurements"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
measurement_type_id = Column(Integer, ForeignKey("measurement_types.id"), nullable=False)
|
||||
value = Column(Float, nullable=False)
|
||||
date = Column(String(20), nullable=False)
|
||||
notes = Column(Text)
|
||||
@ -10,10 +10,8 @@ class User(Base):
|
||||
username = Column(String(50), unique=True, nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
display_name = Column(String(100))
|
||||
weight_lb = Column(Float)
|
||||
calorie_goal = Column(Integer)
|
||||
step_goal = Column(Integer)
|
||||
medical_notes = Column(Text)
|
||||
goals = Column(Text)
|
||||
equipment = Column(Text)
|
||||
vital_stats = Column(Text)
|
||||
created_at = Column(String(20))
|
||||
|
||||
@ -37,3 +37,14 @@ class WorkoutSet(Base):
|
||||
weight = Column(Float)
|
||||
rpe = Column(Float)
|
||||
notes = Column(Text)
|
||||
|
||||
|
||||
class WorkoutSnapshot(Base):
|
||||
__tablename__ = "workout_snapshots"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
workout_id = Column(Integer, ForeignKey("workouts.id"), nullable=False)
|
||||
changed_by = Column(String(50))
|
||||
reason = Column(Text)
|
||||
sets_snapshot = Column(Text) # JSON array
|
||||
created_at = Column(String(20))
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from fastapi import APIRouter, Header, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
@ -5,7 +7,7 @@ from sqlalchemy import select
|
||||
from app.config import AGENT_API_KEY
|
||||
from app.models.base import async_session
|
||||
from app.models.user import User
|
||||
from app.models.workout import Phase, Workout, WorkoutSet
|
||||
from app.models.workout import Phase, Workout, WorkoutSet, WorkoutSnapshot
|
||||
from app.models.checkin import Checkin
|
||||
|
||||
router = APIRouter(prefix="/api/agent", tags=["agent"])
|
||||
@ -16,6 +18,39 @@ async def verify_agent(x_api_key: str = Header("")):
|
||||
raise HTTPException(status_code=403, detail="invalid api key")
|
||||
|
||||
|
||||
async def snapshot_sets(workout_id: int, changed_by: str, reason: str):
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkoutSet)
|
||||
.where(WorkoutSet.workout_id == workout_id)
|
||||
.order_by(WorkoutSet.exercise, WorkoutSet.set_number)
|
||||
)
|
||||
current_sets = result.scalars().all()
|
||||
if not current_sets:
|
||||
return
|
||||
snapshot_data = [
|
||||
{
|
||||
"id": s.id,
|
||||
"exercise": s.exercise,
|
||||
"set_number": s.set_number,
|
||||
"reps": s.reps,
|
||||
"weight": s.weight,
|
||||
"rpe": s.rpe,
|
||||
"notes": s.notes,
|
||||
}
|
||||
for s in current_sets
|
||||
]
|
||||
snap = WorkoutSnapshot(
|
||||
workout_id=workout_id,
|
||||
changed_by=changed_by,
|
||||
reason=reason,
|
||||
sets_snapshot=json.dumps(snapshot_data),
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
session.add(snap)
|
||||
await session.commit()
|
||||
|
||||
|
||||
class CreatePhaseRequest(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
@ -39,6 +74,17 @@ class CreateSetRequest(BaseModel):
|
||||
weight: float | None = None
|
||||
rpe: float | None = None
|
||||
notes: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class UpdateSetRequest(BaseModel):
|
||||
exercise: str | None = None
|
||||
set_number: int | None = None
|
||||
reps: int | None = None
|
||||
weight: float | None = None
|
||||
rpe: float | None = None
|
||||
notes: str | None = None
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class CreateCheckinRequest(BaseModel):
|
||||
@ -86,6 +132,9 @@ async def agent_add_set(
|
||||
body: CreateSetRequest,
|
||||
_=Depends(verify_agent),
|
||||
):
|
||||
if body.reason:
|
||||
await snapshot_sets(workout_id, "agent", body.reason)
|
||||
|
||||
async with async_session() as session:
|
||||
ws = WorkoutSet(
|
||||
workout_id=workout_id,
|
||||
@ -103,6 +152,71 @@ async def agent_add_set(
|
||||
return {"id": ws.id, "exercise": ws.exercise, "set_number": ws.set_number}
|
||||
|
||||
|
||||
@router.put("/workouts/{workout_id}/sets/{set_id}")
|
||||
async def agent_update_set(
|
||||
workout_id: int,
|
||||
set_id: int,
|
||||
body: UpdateSetRequest,
|
||||
_=Depends(verify_agent),
|
||||
):
|
||||
if body.reason:
|
||||
await snapshot_sets(workout_id, "agent", body.reason)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkoutSet).where(
|
||||
WorkoutSet.id == set_id,
|
||||
WorkoutSet.workout_id == workout_id,
|
||||
)
|
||||
)
|
||||
ws = result.scalar_one_or_none()
|
||||
if not ws:
|
||||
raise HTTPException(status_code=404, detail="set not found")
|
||||
|
||||
if body.exercise is not None:
|
||||
ws.exercise = body.exercise
|
||||
if body.set_number is not None:
|
||||
ws.set_number = body.set_number
|
||||
if body.reps is not None:
|
||||
ws.reps = body.reps
|
||||
if body.weight is not None:
|
||||
ws.weight = body.weight
|
||||
if body.rpe is not None:
|
||||
ws.rpe = body.rpe
|
||||
if body.notes is not None:
|
||||
ws.notes = body.notes
|
||||
await session.commit()
|
||||
|
||||
return {"id": ws.id, "exercise": ws.exercise, "set_number": ws.set_number}
|
||||
|
||||
|
||||
@router.delete("/workouts/{workout_id}/sets/{set_id}")
|
||||
async def agent_delete_set(
|
||||
workout_id: int,
|
||||
set_id: int,
|
||||
reason: str = "",
|
||||
_=Depends(verify_agent),
|
||||
):
|
||||
if reason:
|
||||
await snapshot_sets(workout_id, "agent", reason)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkoutSet).where(
|
||||
WorkoutSet.id == set_id,
|
||||
WorkoutSet.workout_id == workout_id,
|
||||
)
|
||||
)
|
||||
ws = result.scalar_one_or_none()
|
||||
if not ws:
|
||||
raise HTTPException(status_code=404, detail="set not found")
|
||||
|
||||
await session.delete(ws)
|
||||
await session.commit()
|
||||
|
||||
return {"deleted": set_id}
|
||||
|
||||
|
||||
@router.put("/workouts/{workout_id}/complete")
|
||||
async def agent_complete_workout(
|
||||
workout_id: int,
|
||||
|
||||
@ -6,6 +6,7 @@ from sqlalchemy import select
|
||||
|
||||
from app.models.base import async_session
|
||||
from app.models.user import User
|
||||
from app.models.measurement import Measurement, MeasurementType
|
||||
from app.auth import hash_password, verify_password, create_session
|
||||
|
||||
router = APIRouter()
|
||||
@ -45,6 +46,11 @@ async def register(
|
||||
username: str = Form(),
|
||||
password: str = Form(),
|
||||
display_name: str = Form(default=""),
|
||||
initial_weight: float = Form(default=None),
|
||||
vital_stats: str = Form(default=""),
|
||||
medical_notes: str = Form(default=""),
|
||||
goals: str = Form(default=""),
|
||||
equipment: str = Form(default=""),
|
||||
):
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(User).where(User.username == username))
|
||||
@ -57,14 +63,33 @@ async def register(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
display_name=display_name or username,
|
||||
vital_stats=vital_stats,
|
||||
medical_notes=medical_notes,
|
||||
goals=goals,
|
||||
equipment=equipment,
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
if initial_weight is not None:
|
||||
result = await session.execute(
|
||||
select(MeasurementType).where(MeasurementType.name == "Weight")
|
||||
)
|
||||
wt = result.scalar_one_or_none()
|
||||
if wt:
|
||||
session.add(Measurement(
|
||||
user_id=user.id,
|
||||
measurement_type_id=wt.id,
|
||||
value=initial_weight,
|
||||
date=datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
token = await create_session(user.id)
|
||||
|
||||
resp = RedirectResponse(url="/dashboard", status_code=303)
|
||||
resp = RedirectResponse(url="/chat?first=1", status_code=303)
|
||||
resp.set_cookie(key="session_token", value=token, httponly=True, max_age=86400 * 30)
|
||||
return resp
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
from fastapi import APIRouter, Request, Depends, Form
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
@ -10,15 +10,46 @@ 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.models.measurement import Measurement, MeasurementType
|
||||
from app.auth import get_current_user
|
||||
from app.services.opencode_proxy import query_opencode
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
ONBOARDING_PROMPT = (
|
||||
"I am a new user and this is the first interaction with this application. "
|
||||
"Please review my goals, equipment, medical history, and stats (all in your context). "
|
||||
"Create a high-level training plan that you can use to make weekly plans and daily workouts. "
|
||||
"Ask for more clarity whenever you need it. "
|
||||
"When the workout planning is ready for me to begin, prompt me to switch to the Workouts or Dashboard tab."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/chat/messages")
|
||||
async def get_chat_messages(request: Request, user: User = Depends(get_current_user)):
|
||||
session_id = request.cookies.get("chat_session_id")
|
||||
if not session_id:
|
||||
return JSONResponse([])
|
||||
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(desc(ChatMessage.created_at))
|
||||
.limit(15)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
rows = list(reversed(rows))
|
||||
return JSONResponse([
|
||||
{"role": m.role, "content": m.content, "created_at": m.created_at} for m in rows
|
||||
])
|
||||
|
||||
|
||||
@router.get("/chat", response_class=HTMLResponse)
|
||||
async def chat_page(request: Request, user: User = Depends(get_current_user)):
|
||||
async def chat_page(request: Request, user: User = Depends(get_current_user), first: int = 0):
|
||||
session_id = request.cookies.get("chat_session_id")
|
||||
if not session_id:
|
||||
session_id = str(uuid.uuid4())
|
||||
@ -38,6 +69,8 @@ async def chat_page(request: Request, user: User = Depends(get_current_user)):
|
||||
"user": user,
|
||||
"messages": messages,
|
||||
"session_id": session_id,
|
||||
"first": first,
|
||||
"onboarding_prompt": ONBOARDING_PROMPT,
|
||||
})
|
||||
resp.set_cookie(key="chat_session_id", value=session_id, httponly=True, max_age=86400 * 30)
|
||||
return resp
|
||||
@ -68,6 +101,18 @@ async def chat_send(
|
||||
)
|
||||
recent_checkins = result.scalars().all()
|
||||
|
||||
result = await session.execute(
|
||||
select(Measurement)
|
||||
.join(MeasurementType, Measurement.measurement_type_id == MeasurementType.id)
|
||||
.where(
|
||||
Measurement.user_id == user.id,
|
||||
MeasurementType.name == "Weight",
|
||||
)
|
||||
.order_by(desc(Measurement.date))
|
||||
.limit(1)
|
||||
)
|
||||
latest_weight = result.scalar_one_or_none()
|
||||
|
||||
workout_lines = []
|
||||
for w in recent_workouts:
|
||||
workout_lines.append(f" {w.date} — {w.name} ({w.status})")
|
||||
@ -86,14 +131,15 @@ async def chat_send(
|
||||
parts.append(f"sleep={c.sleep_hours}h")
|
||||
checkin_lines.append(f" {c.date} — {' | '.join(parts)}")
|
||||
|
||||
weight_str = f"{latest_weight.value} lb" if latest_weight else "Not recorded"
|
||||
|
||||
user_context = (
|
||||
f"Username: {user.username}. "
|
||||
f"Weight: {user.weight_lb} lb. "
|
||||
f"Weight: {weight_str}. "
|
||||
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'}. "
|
||||
f"Vital stats: {user.vital_stats or 'Not specified'}. "
|
||||
)
|
||||
if recent_workouts:
|
||||
user_context += "Recent workouts:\n" + "\n".join(workout_lines) + ". "
|
||||
|
||||
@ -8,6 +8,7 @@ from app.models.base import async_session
|
||||
from app.models.user import User
|
||||
from app.models.workout import Phase, Workout
|
||||
from app.models.checkin import Checkin
|
||||
from app.models.measurement import Measurement, MeasurementType
|
||||
from app.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
@ -64,6 +65,18 @@ async def dashboard(request: Request, user: User = Depends(get_current_user)):
|
||||
)
|
||||
this_week = result.scalars().all()
|
||||
|
||||
result = await session.execute(
|
||||
select(Measurement)
|
||||
.join(MeasurementType, Measurement.measurement_type_id == MeasurementType.id)
|
||||
.where(
|
||||
Measurement.user_id == user.id,
|
||||
MeasurementType.name == "Weight",
|
||||
)
|
||||
.order_by(desc(Measurement.date))
|
||||
.limit(1)
|
||||
)
|
||||
latest_weight = result.scalar_one_or_none()
|
||||
|
||||
return templates.TemplateResponse(request, "dashboard.html", {
|
||||
"user": user,
|
||||
"recent_workouts": recent_workouts,
|
||||
@ -72,4 +85,5 @@ async def dashboard(request: Request, user: User = Depends(get_current_user)):
|
||||
"this_week": this_week,
|
||||
"week_start": week_start,
|
||||
"week_end": week_end,
|
||||
"latest_weight": latest_weight.value if latest_weight else None,
|
||||
})
|
||||
|
||||
@ -22,9 +22,7 @@ async def update_profile(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
display_name: str = Form(default=None),
|
||||
weight_lb: float = Form(default=None),
|
||||
calorie_goal: int = Form(default=None),
|
||||
step_goal: int = Form(default=None),
|
||||
vital_stats: str = Form(default=""),
|
||||
medical_notes: str = Form(default=""),
|
||||
goals: str = Form(default=""),
|
||||
equipment: str = Form(default=""),
|
||||
@ -33,12 +31,7 @@ async def update_profile(
|
||||
session.add(user)
|
||||
if display_name is not None:
|
||||
user.display_name = display_name
|
||||
if weight_lb is not None:
|
||||
user.weight_lb = weight_lb
|
||||
if calorie_goal is not None:
|
||||
user.calorie_goal = calorie_goal
|
||||
if step_goal is not None:
|
||||
user.step_goal = step_goal
|
||||
user.vital_stats = vital_stats
|
||||
user.medical_notes = medical_notes
|
||||
user.goals = goals
|
||||
user.equipment = equipment
|
||||
|
||||
@ -142,6 +142,7 @@ async def add_set(
|
||||
|
||||
@router.post("/workouts/{workout_id}/complete")
|
||||
async def complete_workout(
|
||||
request: Request,
|
||||
workout_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
@ -157,4 +158,24 @@ async def complete_workout(
|
||||
workout.status = "complete"
|
||||
await session.commit()
|
||||
|
||||
return RedirectResponse(url=f"/workouts/{workout_id}", status_code=303)
|
||||
if not workout:
|
||||
return RedirectResponse(url="/workouts", status_code=303)
|
||||
|
||||
result = await session.execute(
|
||||
select(WorkoutSet)
|
||||
.where(WorkoutSet.workout_id == workout_id)
|
||||
.order_by(WorkoutSet.exercise, WorkoutSet.set_number)
|
||||
)
|
||||
sets = result.scalars().all()
|
||||
|
||||
phase = None
|
||||
if workout.phase_id:
|
||||
result = await session.execute(select(Phase).where(Phase.id == workout.phase_id))
|
||||
phase = result.scalar_one_or_none()
|
||||
|
||||
return templates.TemplateResponse(request, "workout_detail.html", {
|
||||
"user": user,
|
||||
"workout": workout,
|
||||
"sets": sets,
|
||||
"phase": phase,
|
||||
})
|
||||
|
||||
113
app/static/chat-sidebar.js
Normal file
113
app/static/chat-sidebar.js
Normal file
@ -0,0 +1,113 @@
|
||||
(function() {
|
||||
const sidebar = document.getElementById('chat-sidebar');
|
||||
if (!sidebar) return;
|
||||
|
||||
const messagesEl = document.getElementById('chat-sidebar-messages');
|
||||
const form = document.getElementById('chat-sidebar-form');
|
||||
const input = document.getElementById('chat-sidebar-input');
|
||||
const status = document.getElementById('chat-sidebar-status');
|
||||
|
||||
function setStatus(msg, isError) {
|
||||
if (status) {
|
||||
status.textContent = msg;
|
||||
status.style.color = isError ? 'var(--del-color, #b33)' : 'var(--muted-color, #888)';
|
||||
}
|
||||
}
|
||||
|
||||
function appendMessage(role, content) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-message ' + role;
|
||||
div.innerHTML = '<p>' + content + '</p>';
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
function removeLoading() {
|
||||
const ld = messagesEl.querySelector('.chat-sidebar-loading');
|
||||
if (ld) ld.remove();
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
try {
|
||||
const resp = await fetch('/api/chat/messages');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
removeLoading();
|
||||
for (const m of data) {
|
||||
appendMessage(m.role, m.content);
|
||||
}
|
||||
} catch (e) {
|
||||
removeLoading();
|
||||
messagesEl.innerHTML = '<p class="chat-sidebar-loading">Could not load messages.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(event) {
|
||||
event.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
removeLoading();
|
||||
appendMessage('user', text);
|
||||
input.value = '';
|
||||
input.disabled = true;
|
||||
setStatus('Waiting...');
|
||||
|
||||
const assistantDiv = document.createElement('div');
|
||||
assistantDiv.className = 'chat-message assistant';
|
||||
assistantDiv.innerHTML = '<p><em>Thinking...</em></p>';
|
||||
messagesEl.appendChild(assistantDiv);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('message', text);
|
||||
|
||||
const response = await fetch('/chat', { method: 'POST', body: formData });
|
||||
if (!response.ok) throw new Error('Server returned ' + response.status);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const p = assistantDiv.querySelector('p');
|
||||
p.innerHTML = '';
|
||||
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
p.innerHTML += line.slice(6);
|
||||
}
|
||||
}
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
if (!p.innerHTML.trim()) {
|
||||
p.innerHTML = '<em>No response.</em>';
|
||||
}
|
||||
|
||||
setStatus('');
|
||||
} catch (err) {
|
||||
assistantDiv.querySelector('p').innerHTML = '<em>Error.</em>';
|
||||
setStatus('Connection error. Is opencode serve running?', true);
|
||||
}
|
||||
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
|
||||
if (form) form.addEventListener('submit', sendMessage);
|
||||
if (input) {
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.dispatchEvent(new Event('submit'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadMessages();
|
||||
})();
|
||||
@ -34,26 +34,6 @@
|
||||
background: var(--card-sectionning-background-color);
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 2.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
width: auto;
|
||||
align-self: flex-end;
|
||||
padding: 0.25rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.chat-status {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
@ -62,3 +42,91 @@
|
||||
.nav-link.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* App layout with sidebar */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
max-width: var(--pico-fluid-max-width, 1200px);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--pico-spacing, 1rem);
|
||||
}
|
||||
|
||||
#main-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#chat-sidebar {
|
||||
width: 360px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: calc(100vh - 4rem);
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--muted-border-color, #ccc);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.app-layout.no-sidebar #chat-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Sidebar chat components */
|
||||
.chat-sidebar-header {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--muted-border-color, #ccc);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-sidebar-messages {
|
||||
max-height: calc(100vh - 14rem);
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-sidebar-messages .chat-message {
|
||||
padding: 0.4rem 0.6rem;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.chat-sidebar-messages .chat-message p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-sidebar-loading {
|
||||
color: var(--muted-color, #888);
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.chat-sidebar-form {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-sidebar-form textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 2rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.3rem;
|
||||
}
|
||||
|
||||
.chat-sidebar-form button {
|
||||
width: auto;
|
||||
padding: 0.2rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
#chat-sidebar {
|
||||
display: none;
|
||||
}
|
||||
.app-layout {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@ -6,13 +6,15 @@
|
||||
<title>{% block title %}Fitness{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="https://unpkg.com/htmx.org@2"></script>
|
||||
<script src="/static/chat-sidebar.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="container-fluid">
|
||||
<ul>
|
||||
<li><strong><a href="/dashboard" class="contrast">Fitness</a></strong></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<ul hx-boost="true" hx-target="#main-content" hx-select="#main-content" hx-swap="innerHTML" hx-push-url="true">
|
||||
<li><a href="/dashboard">Dashboard</a></li>
|
||||
<li><a href="/plan">Plan</a></li>
|
||||
<li><a href="/workouts">Workouts</a></li>
|
||||
@ -20,11 +22,18 @@
|
||||
<li><a href="/checkins">Check-ins</a></li>
|
||||
<li><a href="/chat">Chat</a></li>
|
||||
<li><a href="/profile">Profile</a></li>
|
||||
<li><a href="/logout">Logout</a></li>
|
||||
<li><a href="/logout" hx-boost="false">Logout</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<div class="app-layout {% if hide_sidebar %}no-sidebar{% endif %}">
|
||||
<main id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<aside id="chat-sidebar">
|
||||
{% if not hide_sidebar %}
|
||||
{% include "chat_sidebar.html" %}
|
||||
{% endif %}
|
||||
</aside>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}AI Coach{% endblock %}
|
||||
{% set hide_sidebar = True %}
|
||||
{% block content %}
|
||||
<h1>AI Coach</h1>
|
||||
|
||||
@ -12,7 +13,8 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form class="chat-input" id="chat-form" onsubmit="sendMessage(event)">
|
||||
<form class="chat-input" id="chat-form" onsubmit="sendMessage(event)"
|
||||
data-first="{{ first }}" data-onboarding="{{ onboarding_prompt|e }}">
|
||||
<textarea id="chat-input" placeholder="Ask your coach..." rows="3" required autocomplete="off"></textarea>
|
||||
<button type="submit" id="chat-send-btn">Send</button>
|
||||
</form>
|
||||
@ -25,6 +27,9 @@ const chatForm = document.getElementById('chat-form');
|
||||
const chatSendBtn = document.getElementById('chat-send-btn');
|
||||
const chatStatus = document.getElementById('chat-status');
|
||||
|
||||
const IS_FIRST = chatForm.dataset.first === '1';
|
||||
const ONBOARDING_PROMPT = chatForm.dataset.onboarding;
|
||||
|
||||
chatInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@ -106,5 +111,10 @@ async function sendMessage(event) {
|
||||
chatSendBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (IS_FIRST && ONBOARDING_PROMPT) {
|
||||
chatInput.value = ONBOARDING_PROMPT;
|
||||
chatForm.dispatchEvent(new Event('submit'));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
11
app/templates/chat_sidebar.html
Normal file
11
app/templates/chat_sidebar.html
Normal file
@ -0,0 +1,11 @@
|
||||
<div class="chat-sidebar-header">
|
||||
<strong>AI Coach</strong>
|
||||
</div>
|
||||
<div id="chat-sidebar-messages" class="chat-sidebar-messages">
|
||||
<p class="chat-sidebar-loading">Loading...</p>
|
||||
</div>
|
||||
<form id="chat-sidebar-form" class="chat-sidebar-form">
|
||||
<textarea id="chat-sidebar-input" rows="2" placeholder="Ask your coach..." autocomplete="off"></textarea>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
<p id="chat-sidebar-status" class="chat-status"></p>
|
||||
@ -6,15 +6,7 @@
|
||||
<div class="grid">
|
||||
<article>
|
||||
<h3>Weight</h3>
|
||||
<p style="font-size: 2rem;">{{ user.weight_lb or '—' }} lb</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Calorie Goal</h3>
|
||||
<p style="font-size: 2rem;">{{ user.calorie_goal or '—' }}</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Step Goal</h3>
|
||||
<p style="font-size: 2rem;">{{ user.step_goal or '—' }}</p>
|
||||
<p style="font-size: 2rem;">{{ latest_weight or '—' }} lb</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login{% endblock %}
|
||||
{% set hide_sidebar = True %}
|
||||
{% block content %}
|
||||
<article style="max-width: 400px; margin: 4rem auto;">
|
||||
<h1>Login</h1>
|
||||
|
||||
@ -7,20 +7,11 @@
|
||||
Display Name
|
||||
<input type="text" name="display_name" value="{{ user.display_name or '' }}">
|
||||
</label>
|
||||
<div class="grid">
|
||||
<label>
|
||||
Weight (lb)
|
||||
<input type="number" name="weight_lb" step="0.1" value="{{ user.weight_lb or '' }}">
|
||||
</label>
|
||||
<label>
|
||||
Calorie Goal
|
||||
<input type="number" name="calorie_goal" value="{{ user.calorie_goal or '' }}">
|
||||
</label>
|
||||
<label>
|
||||
Step Goal
|
||||
<input type="number" name="step_goal" value="{{ user.step_goal or '' }}">
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Vital Stats
|
||||
<textarea name="vital_stats" rows="5" placeholder="Birth Date: Height: Gender: Other:">{{ user.vital_stats or '' }}</textarea>
|
||||
</label>
|
||||
<small>Free-form stats passed to your AI coach.</small>
|
||||
<label>
|
||||
Medical Notes
|
||||
<textarea name="medical_notes" rows="4">{{ user.medical_notes or '' }}</textarea>
|
||||
|
||||
@ -1,26 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Register{% endblock %}
|
||||
{% set hide_sidebar = True %}
|
||||
{% block content %}
|
||||
<article style="max-width: 400px; margin: 4rem auto;">
|
||||
<article style="max-width: 600px; margin: 4rem auto;">
|
||||
<h1>Register</h1>
|
||||
{% if error %}
|
||||
<p style="color: var(--red)">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/register">
|
||||
<h3>Account</h3>
|
||||
<label>
|
||||
Username
|
||||
<input type="text" name="username" required autocomplete="username">
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" required autocomplete="new-password">
|
||||
</label>
|
||||
|
||||
<h3>Profile</h3>
|
||||
<label>
|
||||
Display Name
|
||||
<input type="text" name="display_name" autocomplete="name">
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" required autocomplete="new-password">
|
||||
Initial Weight (lb)
|
||||
<input type="number" name="initial_weight" step="0.1">
|
||||
</label>
|
||||
<label>
|
||||
Vital Stats
|
||||
<textarea name="vital_stats" rows="5" placeholder="Birth Date: Height: Gender: Other:"></textarea>
|
||||
</label>
|
||||
<small>Free-form stats passed to your AI coach. Fill in what you want.</small>
|
||||
|
||||
<h3>Background</h3>
|
||||
<label>
|
||||
Medical Notes
|
||||
<textarea name="medical_notes" rows="3" placeholder="Injuries, conditions, limitations..."></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Goals
|
||||
<textarea name="goals" rows="3" placeholder="Weight control, strength, endurance..."></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Equipment
|
||||
<textarea name="equipment" rows="3" placeholder="Dumbbells, bench, bands, pull-up bar..."></textarea>
|
||||
</label>
|
||||
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
<p>Already have an account? <a href="/login">Login</a></p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@ -40,44 +40,13 @@
|
||||
<p>No sets logged yet.</p>
|
||||
{% endif %}
|
||||
|
||||
<h3>Add Set</h3>
|
||||
<form method="post" action="/workouts/{{ workout.id }}/add-set" class="set-row">
|
||||
<label>
|
||||
Exercise
|
||||
<input type="text" name="exercise" required list="exercise-list">
|
||||
<datalist id="exercise-list">
|
||||
{% for s in sets %}
|
||||
<option value="{{ s.exercise }}">
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
Set #
|
||||
<input type="number" name="set_number" required min="1">
|
||||
</label>
|
||||
<label>
|
||||
Reps
|
||||
<input type="number" name="reps" min="1">
|
||||
</label>
|
||||
<label>
|
||||
Weight
|
||||
<input type="number" name="weight" step="0.5">
|
||||
</label>
|
||||
<label>
|
||||
RPE
|
||||
<input type="number" name="rpe" min="1" max="10" step="0.5">
|
||||
</label>
|
||||
<label>
|
||||
Notes
|
||||
<input type="text" name="notes">
|
||||
</label>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
|
||||
{% if workout.status == "plan" %}
|
||||
<form method="post" action="/workouts/{{ workout.id }}/complete">
|
||||
<button type="submit" class="secondary">Mark Complete</button>
|
||||
</form>
|
||||
<button type="submit" class="secondary"
|
||||
hx-post="/workouts/{{ workout.id }}/complete"
|
||||
hx-target="#main-content"
|
||||
hx-select="#main-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="/workouts/{{ workout.id }}">Mark Complete</button>
|
||||
{% endif %}
|
||||
|
||||
<a href="/workouts">Back to Workouts</a>
|
||||
|
||||
0
fitness.db
Normal file
0
fitness.db
Normal file
@ -50,6 +50,15 @@ honest. You are their single point of contact for training chat.
|
||||
Mid-range movements only (e.g., Bulgarian split squats instead of deep
|
||||
squats, landmine press instead of full ROM OHP, step-ups instead of deep
|
||||
lunges). Apply this to squat, hinge, push, pull, and core movements alike.
|
||||
- **Chat sidebar is always available.** Your chat interface appears on the
|
||||
right side of the app on desktop. The user can message you from any page
|
||||
(dashboard, workout detail, etc.). Be responsive to mid-workout questions
|
||||
and adjustments.
|
||||
- **Post-workout follow-up.** After a workout is marked complete, proactively
|
||||
ask how it went and whether the user should rest or train next. Suggest
|
||||
adjustments to the weekly plan and create the next workout in the database.
|
||||
The sidebar context includes the user's recent workout status, so check
|
||||
whether the most recent workout was just completed.
|
||||
|
||||
## API — Creating Workouts and Check-ins
|
||||
|
||||
@ -77,7 +86,19 @@ Body: { username, name, date, phase_id?, notes? }
|
||||
**Add a set to a workout:**
|
||||
```
|
||||
POST /api/agent/workouts/{id}/sets
|
||||
Body: { exercise, set_number, reps?, weight?, rpe?, notes? }
|
||||
Body: { exercise, set_number, reps?, weight?, rpe?, notes?, reason? }
|
||||
```
|
||||
Include `reason` when making changes mid-workout — it triggers a snapshot.
|
||||
|
||||
**Update a set (mid-workout adjustments):**
|
||||
```
|
||||
PUT /api/agent/workouts/{id}/sets/{set_id}
|
||||
Body: { exercise?, set_number?, reps?, weight?, rpe?, notes?, reason? }
|
||||
```
|
||||
|
||||
**Delete a set:**
|
||||
```
|
||||
DELETE /api/agent/workouts/{id}/sets/{set_id}?reason=...
|
||||
```
|
||||
|
||||
**Mark a workout complete:**
|
||||
@ -110,6 +131,10 @@ Body: { name, description?, start_date?, end_date?, notes? }
|
||||
|
||||
Always use the username from the context provided with each message.
|
||||
|
||||
Whenever you modify a workout's sets (add, update, delete), include a
|
||||
`reason` field describing why. This triggers a snapshot of the sets before
|
||||
the change, preserving the previous state for review.
|
||||
|
||||
## Managing the Training Plan
|
||||
|
||||
You maintain a training plan broken into phases. The plan lives in the
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"""
|
||||
Seed the database with initial data (exercises, phases).
|
||||
Seed the database with initial data (exercises, phases, measurement types).
|
||||
Run with: python scripts/seed.py
|
||||
"""
|
||||
import asyncio
|
||||
@ -12,10 +12,10 @@ from sqlalchemy import select
|
||||
from app.models import async_session
|
||||
from app.models.exercise import Exercise
|
||||
from app.models.workout import Phase
|
||||
from app.models.measurement import MeasurementType
|
||||
|
||||
|
||||
EXERCISES = [
|
||||
# Push
|
||||
("Bench Press", "chest", "barbell, bench"),
|
||||
("Incline Dumbbell Press", "chest", "dumbbell, bench"),
|
||||
("Overhead Press", "shoulders", "barbell"),
|
||||
@ -24,7 +24,6 @@ EXERCISES = [
|
||||
("Lateral Raise", "shoulders", "dumbbell"),
|
||||
("Tricep Pushdown", "triceps", "cable, bands"),
|
||||
("Dip", "chest", "dip station"),
|
||||
# Pull
|
||||
("Barbell Row", "back", "barbell"),
|
||||
("Dumbbell Row", "back", "dumbbell, bench"),
|
||||
("Lat Pulldown", "back", "cable, bands"),
|
||||
@ -32,7 +31,6 @@ EXERCISES = [
|
||||
("Face Pull", "shoulders", "cable, bands"),
|
||||
("YTW", "shoulders", "dumbbell, bands"),
|
||||
("Bicep Curl", "biceps", "dumbbell"),
|
||||
# Legs
|
||||
("Barbell Squat", "quadriceps", "barbell, squat rack"),
|
||||
("Goblet Squat", "quadriceps", "dumbbell, kettlebell"),
|
||||
("Bulgarian Split Squat", "quadriceps", "dumbbell, bench"),
|
||||
@ -42,19 +40,16 @@ EXERCISES = [
|
||||
("Leg Curl", "hamstrings", "cable, bands"),
|
||||
("Calf Raise", "calves", "barbell, dumbbell"),
|
||||
("Deadlift", "back", "barbell"),
|
||||
# Core
|
||||
("Dead Bug", "core", "bodyweight"),
|
||||
("Pallof Press", "core", "cable, bands"),
|
||||
("Plank", "core", "bodyweight"),
|
||||
("Ab Wheel Rollout", "core", "ab wheel"),
|
||||
("Russian Twist", "core", "bodyweight, dumbbell"),
|
||||
("Hanging Knee Raise", "core", "pull-up bar"),
|
||||
# Cardio
|
||||
("BikeErg", "cardio", "bikeerg"),
|
||||
("RowErg", "cardio", "rowerg"),
|
||||
("Jump Rope", "cardio", "jump rope"),
|
||||
("Walking", "cardio", "bodyweight"),
|
||||
# Accessory
|
||||
("Farmer's Carry", "grip", "dumbbell, kettlebell"),
|
||||
("Bird Dog", "core", "bodyweight"),
|
||||
("Glute Bridge", "glutes", "bodyweight"),
|
||||
@ -69,6 +64,17 @@ PHASES = [
|
||||
("Strength Building", "Phase 3: Normal training. RPE 8-9, full ROM.", None, None, "Weeks 8+"),
|
||||
]
|
||||
|
||||
MEASUREMENT_TYPES = [
|
||||
("Weight", "Body weight measurement", "lb", "Weigh yourself first thing in the morning, after using the bathroom, before eating or drinking."),
|
||||
("Waist", "Waist circumference", "in", "Measure at the narrowest point of your waist, typically just above the belly button."),
|
||||
("Neck", "Neck circumference", "in", "Measure at the narrowest point, just below the Adam's apple."),
|
||||
("Chest", "Chest circumference", "in", "Measure at the fullest part of the chest, arms relaxed at sides."),
|
||||
("Arm", "Upper arm circumference", "in", "Measure at the midpoint between shoulder and elbow, arm relaxed."),
|
||||
("Thigh", "Thigh circumference", "in", "Measure at the midpoint between hip and knee."),
|
||||
("Hip", "Hip circumference", "in", "Measure at the widest part of the hips/glutes."),
|
||||
("Body Fat", "Estimated body fat percentage", "%", "Use calipers or smart scale. Consistent method is more important than absolute accuracy."),
|
||||
]
|
||||
|
||||
|
||||
async def seed():
|
||||
async with async_session() as session:
|
||||
@ -88,6 +94,14 @@ async def seed():
|
||||
session.add(Phase(name=name, description=desc, start_date=start, end_date=end, notes=notes))
|
||||
print(f"Seeded {len(PHASES)} phases.")
|
||||
|
||||
result = await session.execute(select(MeasurementType).limit(1))
|
||||
if result.scalar_one_or_none():
|
||||
print("Measurement types already seeded, skipping.")
|
||||
else:
|
||||
for name, desc, unit, instructions in MEASUREMENT_TYPES:
|
||||
session.add(MeasurementType(name=name, description=desc, unit=unit, instructions=instructions))
|
||||
print(f"Seeded {len(MEASUREMENT_TYPES)} measurement types.")
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user