Add AI trainer chat with persistent goals and conversation history
Adds ChatMessage and Goal DB models, five new API endpoints (/api/chat/history, /api/chat, /api/goals CRUD), and a Trainer panel in the SPA with streaming chat, goal management, and ride-aware context. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+275
-1
@@ -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 @@
|
||||
<div id="gen-all-label"></div>
|
||||
</div>
|
||||
<button id="fetch-btn">Fetch new rides from Garmin</button>
|
||||
<button id="trainer-sidebar-btn">Open Trainer</button>
|
||||
</div>
|
||||
|
||||
<div id="fetch-modal-overlay">
|
||||
@@ -295,6 +344,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="trainer-panel">
|
||||
<div id="goals-section">
|
||||
<div id="goals-header">
|
||||
<div class="section-title">Goals</div>
|
||||
<button id="add-goal-btn" title="Add goal">+</button>
|
||||
</div>
|
||||
<div id="goals-list">
|
||||
<div id="no-goals-msg">No goals set. Add one to give your trainer context.</div>
|
||||
</div>
|
||||
<div id="goal-add-form">
|
||||
<input id="goal-title-input" type="text" placeholder="Goal (e.g. Complete a 100km ride)">
|
||||
<textarea id="goal-desc-input" placeholder="Details (optional)"></textarea>
|
||||
<div class="goal-form-actions">
|
||||
<button id="goal-cancel-btn">Cancel</button>
|
||||
<button id="goal-save-btn">Save Goal</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chat-messages"></div>
|
||||
<div id="chat-input-bar">
|
||||
<textarea id="chat-input" rows="1" placeholder="Ask your trainer…"></textarea>
|
||||
<button id="chat-send-btn" title="Send">
|
||||
<svg viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22,2 15,22 11,13 2,9"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="mobile-nav">
|
||||
<button class="mobile-nav-btn active" data-tab="list" onclick="setMobileTab('list')">
|
||||
<svg viewBox="0 0 24 24"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
@@ -308,6 +384,10 @@
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
Details
|
||||
</button>
|
||||
<button class="mobile-nav-btn" data-tab="trainer" onclick="setMobileTab('trainer')">
|
||||
<svg viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
Trainer
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
@@ -877,11 +957,205 @@ function setMobileTab(tab) {
|
||||
document.getElementById('sidebar').classList.toggle('mobile-active', tab === 'list');
|
||||
document.getElementById('main').classList.toggle('mobile-active', tab === 'map');
|
||||
document.getElementById('details').classList.toggle('mobile-active', tab === 'details');
|
||||
document.getElementById('trainer-panel').classList.toggle('mobile-active', tab === 'trainer');
|
||||
document.querySelectorAll('.mobile-nav-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab));
|
||||
if (tab === 'map') setTimeout(() => map.invalidateSize(), 50);
|
||||
if (tab === 'trainer') openTrainerPanel();
|
||||
}
|
||||
setMobileTab('list');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trainer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let goals = [];
|
||||
let goalsLoaded = false;
|
||||
let chatLoaded = false;
|
||||
let trainerActive = false;
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function openTrainerPanel() {
|
||||
if (!goalsLoaded) loadGoals();
|
||||
if (!chatLoaded) loadChatHistory();
|
||||
}
|
||||
|
||||
function setTrainerMode(active) {
|
||||
if (window.matchMedia('(max-width: 768px)').matches) {
|
||||
if (active) setMobileTab('trainer');
|
||||
return;
|
||||
}
|
||||
trainerActive = active;
|
||||
document.getElementById('main').style.display = active ? 'none' : '';
|
||||
document.getElementById('details').style.display = active ? 'none' : '';
|
||||
document.getElementById('trainer-panel').classList.toggle('trainer-active', active);
|
||||
document.getElementById('trainer-sidebar-btn').textContent = active ? 'Back to Rides' : 'Open Trainer';
|
||||
if (active) openTrainerPanel();
|
||||
}
|
||||
|
||||
document.getElementById('trainer-sidebar-btn').addEventListener('click', () => setTrainerMode(!trainerActive));
|
||||
|
||||
// Goals
|
||||
async function loadGoals() {
|
||||
goalsLoaded = true;
|
||||
const res = await fetch('/api/goals');
|
||||
goals = await res.json();
|
||||
renderGoals();
|
||||
}
|
||||
|
||||
function renderGoals() {
|
||||
const list = document.getElementById('goals-list');
|
||||
if (!goals.length) {
|
||||
list.innerHTML = '<div id="no-goals-msg">No goals set. Add one to give your trainer context.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = goals.map(g => `
|
||||
<div class="goal-item">
|
||||
<div class="goal-text">
|
||||
<div class="goal-title">${escHtml(g.title)}</div>
|
||||
${g.description ? `<div class="goal-desc">${escHtml(g.description)}</div>` : ''}
|
||||
</div>
|
||||
<button class="goal-delete" data-id="${g.id}" title="Remove goal">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
list.querySelectorAll('.goal-delete').forEach(btn => {
|
||||
btn.addEventListener('click', () => deleteGoal(btn.dataset.id));
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteGoal(id) {
|
||||
await fetch(`/api/goals/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
goals = goals.filter(g => g.id !== id);
|
||||
renderGoals();
|
||||
}
|
||||
|
||||
document.getElementById('add-goal-btn').addEventListener('click', () => {
|
||||
document.getElementById('goal-add-form').classList.add('open');
|
||||
document.getElementById('goal-title-input').focus();
|
||||
});
|
||||
|
||||
document.getElementById('goal-cancel-btn').addEventListener('click', () => {
|
||||
document.getElementById('goal-add-form').classList.remove('open');
|
||||
document.getElementById('goal-title-input').value = '';
|
||||
document.getElementById('goal-desc-input').value = '';
|
||||
});
|
||||
|
||||
document.getElementById('goal-save-btn').addEventListener('click', async () => {
|
||||
const title = document.getElementById('goal-title-input').value.trim();
|
||||
if (!title) return;
|
||||
const desc = document.getElementById('goal-desc-input').value.trim();
|
||||
const res = await fetch('/api/goals', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, description: desc }),
|
||||
});
|
||||
const newGoal = await res.json();
|
||||
goals.push(newGoal);
|
||||
renderGoals();
|
||||
document.getElementById('goal-cancel-btn').click();
|
||||
});
|
||||
|
||||
// Chat
|
||||
function scrollChatBottom() {
|
||||
const c = document.getElementById('chat-messages');
|
||||
c.scrollTop = c.scrollHeight;
|
||||
}
|
||||
|
||||
function appendChatBubble(role, content, isoTime) {
|
||||
const container = document.getElementById('chat-messages');
|
||||
const div = document.createElement('div');
|
||||
div.className = `chat-msg ${role}`;
|
||||
const timeStr = isoTime
|
||||
? new Date(isoTime).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||
: '';
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'chat-bubble';
|
||||
bubble.textContent = content;
|
||||
div.appendChild(bubble);
|
||||
if (timeStr) {
|
||||
const t = document.createElement('div');
|
||||
t.className = 'chat-time';
|
||||
t.textContent = timeStr;
|
||||
div.appendChild(t);
|
||||
}
|
||||
container.appendChild(div);
|
||||
return bubble;
|
||||
}
|
||||
|
||||
async function loadChatHistory() {
|
||||
chatLoaded = true;
|
||||
const res = await fetch('/api/chat/history');
|
||||
const history = await res.json();
|
||||
const container = document.getElementById('chat-messages');
|
||||
container.innerHTML = '';
|
||||
for (const msg of history) appendChatBubble(msg.role, msg.content, msg.created_at);
|
||||
scrollChatBottom();
|
||||
}
|
||||
|
||||
async function sendChatMessage() {
|
||||
const input = document.getElementById('chat-input');
|
||||
const sendBtn = document.getElementById('chat-send-btn');
|
||||
const message = input.value.trim();
|
||||
if (!message || sendBtn.disabled) return;
|
||||
|
||||
input.value = '';
|
||||
input.style.height = '';
|
||||
sendBtn.disabled = true;
|
||||
|
||||
appendChatBubble('user', message, new Date().toISOString());
|
||||
scrollChatBottom();
|
||||
|
||||
const container = document.getElementById('chat-messages');
|
||||
const assistantDiv = document.createElement('div');
|
||||
assistantDiv.className = 'chat-msg assistant';
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'chat-bubble';
|
||||
bubble.innerHTML = '<span class="blinking"></span>';
|
||||
assistantDiv.appendChild(bubble);
|
||||
container.appendChild(assistantDiv);
|
||||
scrollChatBottom();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
bubble.textContent = text;
|
||||
scrollChatBottom();
|
||||
}
|
||||
bubble.textContent = text;
|
||||
const timeDiv = document.createElement('div');
|
||||
timeDiv.className = 'chat-time';
|
||||
timeDiv.textContent = new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
assistantDiv.appendChild(timeDiv);
|
||||
} catch (e) {
|
||||
bubble.textContent = `Error: ${e.message}`;
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('chat-send-btn').addEventListener('click', sendChatMessage);
|
||||
document.getElementById('chat-input').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
|
||||
});
|
||||
document.getElementById('chat-input').addEventListener('input', function () {
|
||||
this.style.height = '';
|
||||
this.style.height = Math.min(this.scrollHeight, 100) + 'px';
|
||||
});
|
||||
|
||||
async function initAuth() {
|
||||
const res = await fetch('/api/me');
|
||||
if (res.status === 401) {
|
||||
|
||||
Reference in New Issue
Block a user