162 lines
5.4 KiB
JavaScript
162 lines
5.4 KiB
JavaScript
import { useState, useRef, useEffect } from 'react'
|
|
import { marked } from 'marked'
|
|
import Dashboard from './Dashboard'
|
|
import './App.css'
|
|
import './Dashboard.css'
|
|
|
|
marked.setOptions({ breaks: true, gfm: true })
|
|
|
|
const API_URL = window.location.hostname === 'localhost'
|
|
? 'http://localhost:4003'
|
|
: 'https://diet-api.psbailey.uk'
|
|
|
|
export default function App() {
|
|
const [page, setPage] = useState('chat')
|
|
const [messages, setMessages] = useState([])
|
|
const [input, setInput] = useState('')
|
|
const [images, setImages] = useState([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [restarting, setRestarting] = useState(false)
|
|
const messagesEnd = useRef(null)
|
|
const inputRef = useRef(null)
|
|
const fileRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [messages])
|
|
|
|
function handleImage(e) {
|
|
const files = Array.from(e.target.files || [])
|
|
for (const file of files) {
|
|
const reader = new FileReader()
|
|
reader.onload = () => {
|
|
const base64 = reader.result.split(',')[1]
|
|
setImages(prev => [...prev, { base64, preview: reader.result, mime: file.type }])
|
|
}
|
|
reader.readAsDataURL(file)
|
|
}
|
|
}
|
|
|
|
function removeImage(idx) {
|
|
setImages(prev => prev.filter((_, i) => i !== idx))
|
|
if (fileRef.current) fileRef.current.value = ''
|
|
}
|
|
|
|
async function send(e) {
|
|
e.preventDefault()
|
|
const text = input.trim()
|
|
if ((!text && !images.length) || loading) return
|
|
|
|
setInput('')
|
|
const userMsg = { role: 'user', content: text, images: images.map(i => i.preview) }
|
|
setMessages(prev => [...prev, userMsg])
|
|
setLoading(true)
|
|
|
|
const payload = { message: text || 'Analyse these images' }
|
|
if (images.length) payload.images = images.map(i => ({ base64: i.base64, mime: i.mime }))
|
|
setImages([])
|
|
if (fileRef.current) fileRef.current.value = ''
|
|
|
|
try {
|
|
const res = await fetch(`${API_URL}/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
})
|
|
|
|
if (!res.ok) throw new Error('Failed')
|
|
|
|
const reader = res.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let assistantMsg = ''
|
|
|
|
setMessages(prev => [...prev, { role: 'assistant', content: '' }])
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
const chunk = decoder.decode(value, { stream: true })
|
|
for (const line of chunk.split('\n')) {
|
|
if (line.startsWith('data: ')) {
|
|
const data = line.slice(6)
|
|
if (data === '[DONE]') break
|
|
assistantMsg += data.replace(/\\n/g, '\n')
|
|
setMessages(prev => {
|
|
const updated = [...prev]
|
|
updated[updated.length - 1] = { role: 'assistant', content: assistantMsg }
|
|
return updated
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
setMessages(prev => [...prev, { role: 'assistant', content: '⚠️ Connection error. Try again.' }])
|
|
} finally {
|
|
setLoading(false)
|
|
inputRef.current?.focus()
|
|
}
|
|
}
|
|
|
|
if (page === 'dashboard') return <Dashboard onBack={() => setPage('chat')} />
|
|
|
|
async function restartApi() {
|
|
setRestarting(true)
|
|
try {
|
|
await fetch(`${API_URL}/restart`, { method: 'POST', headers: { Authorization: 'my-health-data' } })
|
|
} catch {}
|
|
setTimeout(() => setRestarting(false), 5000)
|
|
}
|
|
|
|
return (
|
|
<div className="app">
|
|
<header>
|
|
<h1>🥗 Diet Coach</h1>
|
|
<div className="header-btns">
|
|
<button onClick={restartApi} className="nav-btn" disabled={restarting}>{restarting ? '⏳' : '🔄'} API</button>
|
|
<button onClick={() => setPage('dashboard')} className="nav-btn">📊 Dashboard</button>
|
|
</div>
|
|
</header>
|
|
<div className="messages">
|
|
{messages.length === 0 && (
|
|
<div className="empty">Ask me about your diet, log food, or send a screenshot of your Lose It report.</div>
|
|
)}
|
|
{messages.map((msg, i) => (
|
|
<div key={i} className={`msg ${msg.role}`}>
|
|
{msg.role === 'assistant'
|
|
? <div className="bubble" dangerouslySetInnerHTML={{ __html: marked.parse(msg.content || '...') }} />
|
|
: <div className="bubble">
|
|
{msg.images?.map((src, j) => <img key={j} src={src} className="msg-img" alt="" />)}
|
|
{msg.content}
|
|
</div>
|
|
}
|
|
</div>
|
|
))}
|
|
<div ref={messagesEnd} />
|
|
</div>
|
|
{images.length > 0 && (
|
|
<div className="image-preview">
|
|
{images.map((img, i) => (
|
|
<span key={i} className="preview-item">
|
|
<img src={img.preview} alt="" />
|
|
<button onClick={() => removeImage(i)} className="remove-img">✕</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<form onSubmit={send} className="input-bar">
|
|
<button type="button" className="attach-btn" onClick={() => fileRef.current?.click()} disabled={loading}>📷</button>
|
|
<input type="file" ref={fileRef} accept="image/*" multiple onChange={handleImage} hidden />
|
|
<input
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={e => setInput(e.target.value)}
|
|
placeholder="What did you eat today?"
|
|
disabled={loading}
|
|
autoFocus
|
|
/>
|
|
<button type="submit" disabled={loading || (!input.trim() && !images.length)}>Send</button>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|