Stream C frontend: ChatCoach page, nav update, README with AI providers + agent configs
Frontend: - ChatCoach.jsx: streaming chat UI with SSE, provider indicator, suggestion chips, 'no AI configured' state with setup link - App.jsx: import + /chat route + nav bar (More → Coach with brain emoji) - api.js: getAIStatus() method Documentation: - README.md: 'Built-in AI Coach' section with 8-provider table, external agent config snippets for Claude Desktop, Claude Code, Cursor, Windsurf, and COROS multi-MCP pattern - AGENT_GUIDE.md: multi-device integration, Strava AI/ML warning, provider-agnostic statement Nav bar: Home | Log | Keto | Programs | Coach Settings still reachable at /settings (linked from Coach setup prompt)
This commit is contained in:
parent
3ab88a3d2a
commit
0670bca9b1
5 changed files with 271 additions and 142 deletions
|
|
@ -97,3 +97,31 @@ Use get_context() to see their motivation profile:
|
||||||
understand it, but don't lecture.
|
understand it, but don't lecture.
|
||||||
- Don't read back integration credentials. You can check status but
|
- Don't read back integration credentials. You can check status but
|
||||||
never retrieve stored secrets.
|
never retrieve stored secrets.
|
||||||
|
|
||||||
|
## Multi-Device Integration
|
||||||
|
|
||||||
|
If you have access to both Diligence tools and a device MCP (like COROS),
|
||||||
|
you can bridge data between them:
|
||||||
|
|
||||||
|
1. Read the user's recent workouts from the device MCP
|
||||||
|
2. Use Diligence's log_activity tool to import them
|
||||||
|
3. This earns the user points automatically
|
||||||
|
|
||||||
|
Example workflow with COROS MCP connected:
|
||||||
|
- Check COROS for today's activities
|
||||||
|
- For each unlogged activity, call log_activity with the details
|
||||||
|
- Report the points earned
|
||||||
|
|
||||||
|
## Strava Data Warning
|
||||||
|
|
||||||
|
Strava's API terms prohibit use of their data for AI/ML purposes.
|
||||||
|
If the user has Strava connected, you may see synced activities in
|
||||||
|
their log, but do not reference Strava-specific data in your coaching
|
||||||
|
advice. Treat Strava activities as user-reported workouts only.
|
||||||
|
|
||||||
|
## Provider-Agnostic
|
||||||
|
|
||||||
|
This guide works regardless of which LLM powers the coaching.
|
||||||
|
The same context, tools, and personality apply whether you are
|
||||||
|
GPT-4o, Claude, Llama, Gemini, or any other model. The user
|
||||||
|
chose you — respect their choice and focus on being helpful.
|
||||||
|
|
|
||||||
59
README.md
59
README.md
|
|
@ -108,34 +108,65 @@ You should see 4 containers, all healthy: `frontend`, `backend`, `mcp-connector`
|
||||||
- **Program tracking** — 90-day structured programs (StrongLifts, Darebee, etc.) with day-by-day progression.
|
- **Program tracking** — 90-day structured programs (StrongLifts, Darebee, etc.) with day-by-day progression.
|
||||||
- **Configurable rewards** — you define what's worth earning. Gaming time, screen time, treats — your rules.
|
- **Configurable rewards** — you define what's worth earning. Gaming time, screen time, treats — your rules.
|
||||||
|
|
||||||
## Connecting an AI Agent
|
## Built-in AI Coach
|
||||||
|
|
||||||
Point your agent's MCP config at:
|
Diligence includes a built-in AI coaching chat. Configure any LLM provider in **Settings → Integrations**, then open the **Coach** tab.
|
||||||
|
|
||||||
| Environment | URL |
|
Supported providers (one API key, that's it):
|
||||||
|-------------|-----|
|
|
||||||
| Local (development) | `http://localhost:3001/sse` |
|
|
||||||
| Behind reverse proxy | `https://your-domain/mcp` |
|
|
||||||
|
|
||||||
Works with Claude Desktop, Cursor, Windsurf, and any MCP-compatible agent.
|
| Provider | Free tier | What you get |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| **OpenRouter** | 26 free models | 300+ models from every major provider, one key |
|
||||||
|
| **Hugging Face** | Yes | Thousands of open-source models |
|
||||||
|
| **Groq** | Yes | Ultra-fast Llama inference |
|
||||||
|
| **Ollama** | Local, free | Run any model on your own machine |
|
||||||
|
| **OpenAI** | No | GPT-4o, GPT-4o-mini |
|
||||||
|
| **Claude** | No | Claude Sonnet 4.6, Opus 4.8 |
|
||||||
|
| **Gemini** | Yes | Gemini 2.0 Flash, 2.5 Pro |
|
||||||
|
| **Custom** | — | Any OpenAI-compatible endpoint (vLLM, TGI, LiteLLM) |
|
||||||
|
|
||||||
Copy the contents of [AGENT_GUIDE.md](AGENT_GUIDE.md) into your agent's system instructions for motivation-aware coaching.
|
The AI coach has access to your profile, points, program schedule, and motivation type. It can log workouts, search food, and create meal plans through the chat.
|
||||||
|
|
||||||
### Claude Desktop example
|
### Connecting external AI agents (MCP)
|
||||||
|
|
||||||
Add to your `claude_desktop_config.json`:
|
The MCP connector at port 3001 works with any MCP-compatible agent. The built-in chat and external agents coexist — use whichever fits your workflow.
|
||||||
|
|
||||||
|
**Claude Desktop** — add to `claude_desktop_config.json`:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"diligence": {
|
"diligence": { "url": "http://localhost:3001/sse" }
|
||||||
"url": "http://localhost:3001/sse"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then paste the contents of `AGENT_GUIDE.md` into your Claude Desktop project instructions.
|
**Claude Code** (CLI):
|
||||||
|
```bash
|
||||||
|
claude mcp add diligence --transport sse http://localhost:3001/sse
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cursor** — add to `.cursor/mcp.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"diligence": { "url": "http://localhost:3001/sse" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windsurf** — add to MCP settings, same format as Cursor.
|
||||||
|
|
||||||
|
**COROS watch owners** — add both Diligence and COROS MCP servers to your agent. The agent bridges your watch data with your fitness log:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"diligence": { "url": "http://localhost:3001/sse" },
|
||||||
|
"coros": { "url": "https://your-coros-mcp-url/sse" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the contents of [AGENT_GUIDE.md](AGENT_GUIDE.md) into your agent's system instructions for motivation-aware coaching.
|
||||||
|
|
||||||
## Configuring Integrations
|
## Configuring Integrations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
import { Routes, Route, Navigate, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { hasToken, clearToken, api } from './api'
|
|
||||||
import Login from './pages/Login'
|
|
||||||
import Dashboard from './pages/Dashboard'
|
|
||||||
import LogActivity from './pages/LogActivity'
|
|
||||||
import LogFood from './pages/LogFood'
|
|
||||||
import Nutrition from './pages/Nutrition'
|
|
||||||
import Rewards from './pages/Rewards'
|
|
||||||
import Settings from './pages/Settings'
|
|
||||||
import Onboarding from './pages/Onboarding'
|
|
||||||
import WeekView from './pages/WeekView'
|
|
||||||
import Welcome from './pages/Welcome'
|
|
||||||
import SettingsIntegrations from './pages/SettingsIntegrations'
|
|
||||||
import MealPlan from './pages/MealPlan'
|
|
||||||
import ProgramSearch from './pages/ProgramSearch'
|
|
||||||
import ProgramDetail from './pages/ProgramDetail'
|
|
||||||
import CatalogDetail from './pages/CatalogDetail'
|
|
||||||
import Support from './pages/Support'
|
|
||||||
import SupportAdmin from './pages/SupportAdmin'
|
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
|
||||||
if (!hasToken()) return <Navigate to="/login" />
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
|
|
||||||
function HelpButton() {
|
|
||||||
const [unread, setUnread] = useState(0)
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
// Don't show on login/onboarding/support pages
|
|
||||||
const hidden = ['/login', '/onboarding', '/support'].some(p => location.pathname.startsWith(p))
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hidden || !hasToken()) return
|
|
||||||
api.getUnreadCount()
|
|
||||||
.then(d => setUnread(d.unread || 0))
|
|
||||||
.catch(() => {})
|
|
||||||
// Poll every 60s for new replies
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
api.getUnreadCount()
|
|
||||||
.then(d => setUnread(d.unread || 0))
|
|
||||||
.catch(() => {})
|
|
||||||
}, 60000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [location.pathname, hidden])
|
|
||||||
|
|
||||||
if (hidden) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/support')}
|
|
||||||
style={{
|
|
||||||
position: 'fixed', top: '12px', right: '12px', zIndex: 90,
|
|
||||||
width: '40px', height: '40px', borderRadius: '50%',
|
|
||||||
background: 'var(--card)', boxShadow: 'var(--shadow-2)',
|
|
||||||
border: '1px solid var(--divider)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: '1.1rem', padding: 0, cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
?
|
|
||||||
{unread > 0 && (
|
|
||||||
<span style={{
|
|
||||||
position: 'absolute', top: '-4px', right: '-4px',
|
|
||||||
background: 'var(--red)', color: '#fff', fontSize: '0.6rem', fontWeight: 800,
|
|
||||||
width: '18px', height: '18px', borderRadius: '50%',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
border: '2px solid var(--bg)',
|
|
||||||
}}>
|
|
||||||
{unread}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavBar() {
|
|
||||||
return (
|
|
||||||
<nav className="nav-bar">
|
|
||||||
<NavLink to="/" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
|
||||||
<span className="nav-icon">🏠</span> Home
|
|
||||||
</NavLink>
|
|
||||||
<NavLink to="/log" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
|
||||||
<span className="nav-icon">💪</span> Log
|
|
||||||
</NavLink>
|
|
||||||
<NavLink to="/nutrition" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
|
||||||
<span className="nav-icon">🥑</span> Keto
|
|
||||||
</NavLink>
|
|
||||||
<NavLink to="/programs" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
|
||||||
<span className="nav-icon">📋</span> Programs
|
|
||||||
</NavLink>
|
|
||||||
<NavLink to="/settings" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
|
||||||
<span className="nav-icon">⚙️</span> More
|
|
||||||
</NavLink>
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{hasToken() && <HelpButton />}
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
<Route path="/onboarding" element={<ProtectedRoute><Onboarding /></ProtectedRoute>} />
|
|
||||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
|
||||||
<Route path="/log" element={<ProtectedRoute><LogActivity /></ProtectedRoute>} />
|
|
||||||
<Route path="/food" element={<ProtectedRoute><LogFood /></ProtectedRoute>} />
|
|
||||||
<Route path="/nutrition" element={<ProtectedRoute><Nutrition /></ProtectedRoute>} />
|
|
||||||
<Route path="/programs" element={<ProtectedRoute><ProgramSearch /></ProtectedRoute>} />
|
|
||||||
<Route path="/programs/:id" element={<ProtectedRoute><ProgramDetail /></ProtectedRoute>} />
|
|
||||||
<Route path="/catalog/:id" element={<ProtectedRoute><CatalogDetail /></ProtectedRoute>} />
|
|
||||||
<Route path="/rewards" element={<ProtectedRoute><Rewards /></ProtectedRoute>} />
|
|
||||||
<Route path="/week" element={<ProtectedRoute><WeekView /></ProtectedRoute>} />
|
|
||||||
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
|
||||||
<Route path="/support" element={<ProtectedRoute><Support /></ProtectedRoute>} />
|
|
||||||
<Route path="/support/admin" element={<ProtectedRoute><SupportAdmin /></ProtectedRoute>} />
|
|
||||||
<Route path="/support/admin/:threadId" element={<ProtectedRoute><SupportAdmin /></ProtectedRoute>} />
|
|
||||||
<Route path="/welcome" element={<Welcome />} />
|
|
||||||
<Route path="/settings/integrations" element={<ProtectedRoute><SettingsIntegrations /></ProtectedRoute>} />
|
|
||||||
<Route path="/meal-plan" element={<ProtectedRoute><MealPlan /></ProtectedRoute>} />
|
|
||||||
</Routes>
|
|
||||||
{hasToken() && <NavBar />}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -139,6 +139,10 @@ export const api = {
|
||||||
listProviders: () => request('/integrations/providers'),
|
listProviders: () => request('/integrations/providers'),
|
||||||
configureIntegration: (provider, credentials) =>
|
configureIntegration: (provider, credentials) =>
|
||||||
request('/integrations/configure', { method: 'POST', body: JSON.stringify({ provider, credentials }) }),
|
request('/integrations/configure', { method: 'POST', body: JSON.stringify({ provider, credentials }) }),
|
||||||
|
// AI Coaching
|
||||||
|
getAIStatus: () => request('/ai/status'),
|
||||||
|
// Note: chatWithAI uses fetch directly in ChatCoach.jsx for SSE streaming
|
||||||
|
|
||||||
// Resources
|
// Resources
|
||||||
getResourceRecommendations: () => request('/onboarding/recommendations'),
|
getResourceRecommendations: () => request('/onboarding/recommendations'),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
194
frontend/src/pages/ChatCoach.jsx
Normal file
194
frontend/src/pages/ChatCoach.jsx
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
export default function ChatCoach() {
|
||||||
|
const [messages, setMessages] = useState([])
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [streaming, setStreaming] = useState(false)
|
||||||
|
const [aiStatus, setAiStatus] = useState(null)
|
||||||
|
const scrollRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getAIStatus().then(setAiStatus).catch(() => setAiStatus({ configured: false }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
const text = input.trim()
|
||||||
|
if (!text || streaming) return
|
||||||
|
|
||||||
|
const userMsg = { role: 'user', content: text }
|
||||||
|
setMessages(prev => [...prev, userMsg])
|
||||||
|
setInput('')
|
||||||
|
setStreaming(true)
|
||||||
|
|
||||||
|
// Add placeholder for assistant
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: '' }])
|
||||||
|
|
||||||
|
try {
|
||||||
|
const history = messages.map(m => ({ role: m.role, content: m.content }))
|
||||||
|
const token = localStorage.getItem('fitness_token')
|
||||||
|
const resp = await fetch('/api/ai/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message: text, history }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const reader = resp.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
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: ') && line.trim() !== 'data: [DONE]') {
|
||||||
|
try {
|
||||||
|
const { text: chunk } = JSON.parse(line.slice(6))
|
||||||
|
if (chunk) {
|
||||||
|
setMessages(prev => {
|
||||||
|
const updated = [...prev]
|
||||||
|
const last = updated[updated.length - 1]
|
||||||
|
updated[updated.length - 1] = { ...last, content: last.content + chunk }
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setMessages(prev => {
|
||||||
|
const updated = [...prev]
|
||||||
|
updated[updated.length - 1] = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Connection error. Check that the backend is running and an AI provider is configured in Settings → Integrations.',
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setStreaming(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = [
|
||||||
|
'What should I eat today?',
|
||||||
|
'Log my 30-minute run',
|
||||||
|
'How am I doing this week?',
|
||||||
|
'Create a meal plan for me',
|
||||||
|
]
|
||||||
|
|
||||||
|
if (aiStatus && !aiStatus.configured) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h1 className="page-title">AI Coach</h1>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '32px 20px' }}>
|
||||||
|
<div style={{ fontSize: '2rem', marginBottom: '12px' }}>🤖</div>
|
||||||
|
<h3 style={{ marginBottom: '8px' }}>No AI provider connected</h3>
|
||||||
|
<p style={{ color: 'var(--text-2)', fontSize: '0.9rem', marginBottom: '16px' }}>
|
||||||
|
Connect OpenAI, OpenRouter, Claude, Ollama, or any other provider to start chatting with your AI fitness coach.
|
||||||
|
</p>
|
||||||
|
<a href="/settings/integrations" className="btn-primary" style={{
|
||||||
|
display: 'inline-block', padding: '12px 24px', borderRadius: 'var(--r)',
|
||||||
|
background: 'var(--accent)', color: 'var(--text-inv)', textDecoration: 'none',
|
||||||
|
fontWeight: 600, fontSize: '0.88rem',
|
||||||
|
}}>
|
||||||
|
Configure AI Provider
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page" style={{ display: 'flex', flexDirection: 'column', paddingBottom: '96px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||||
|
<h1 className="page-title" style={{ marginBottom: 0 }}>AI Coach</h1>
|
||||||
|
{aiStatus?.provider && (
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--text-3)',
|
||||||
|
background: 'var(--accent-bg)', padding: '4px 10px', borderRadius: 'var(--r-full)',
|
||||||
|
}}>
|
||||||
|
{aiStatus.provider} · {aiStatus.model}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', marginBottom: '12px' }}>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||||
|
<p style={{ color: 'var(--text-3)', fontSize: '0.9rem', marginBottom: '16px' }}>
|
||||||
|
Ask me anything about your fitness, nutrition, or program.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'center' }}>
|
||||||
|
{suggestions.map(s => (
|
||||||
|
<button key={s} className="btn-outline btn-sm" onClick={() => { setInput(s); }}
|
||||||
|
style={{ fontSize: '0.8rem' }}>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
maxWidth: '85%',
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: msg.role === 'user'
|
||||||
|
? 'var(--r-lg) var(--r-lg) var(--r-sm) var(--r-lg)'
|
||||||
|
: 'var(--r-lg) var(--r-lg) var(--r-lg) var(--r-sm)',
|
||||||
|
background: msg.role === 'user' ? 'var(--accent)' : 'var(--card)',
|
||||||
|
color: msg.role === 'user' ? 'var(--text-inv)' : 'var(--text)',
|
||||||
|
border: msg.role === 'user' ? 'none' : '1px solid var(--card-border)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}>
|
||||||
|
{msg.content || (streaming && i === messages.length - 1 ? '...' : '')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={scrollRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: '8px',
|
||||||
|
position: 'sticky', bottom: '72px',
|
||||||
|
background: 'var(--bg)', padding: '8px 0',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendMessage()}
|
||||||
|
placeholder={streaming ? 'Thinking...' : 'Ask your AI coach...'}
|
||||||
|
disabled={streaming}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={sendMessage}
|
||||||
|
disabled={streaming || !input.trim()}
|
||||||
|
className="btn-primary"
|
||||||
|
style={{ padding: '12px 18px', minWidth: 'auto' }}
|
||||||
|
>
|
||||||
|
{streaming ? '···' : '→'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue