import { useEffect, useRef, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { api } from '../services/api'; import { streamChatMessage } from '../services/streaming'; import { ToolCallCard } from '../components/ToolCallCard'; import { MessageContent } from '../components/MessageContent'; import { Patient, ChatMessage, ToolCall } from '../types'; import './ChatPage.css'; function formatTime(ts: string) { return new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', }); } export function ChatPage() { const { patientId } = useParams<{ patientId: string }>(); const navigate = useNavigate(); const [patient, setPatient] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [selectedImage, setSelectedImage] = useState(null); const [imagePreview, setImagePreview] = useState(null); const [isStreaming, setIsStreaming] = useState(false); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); const textareaRef = useRef(null); useEffect(() => { if (!patientId) return; api.getPatient(patientId).then(res => setPatient(res.patient)); api.getChatHistory(patientId).then(res => setMessages(res.messages ?? [])); }, [patientId]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); const handleImageSelect = (file: File) => { setSelectedImage(file); setImagePreview(URL.createObjectURL(file)); }; const handleSend = async () => { if ((!input.trim() && !selectedImage) || !patientId || isStreaming) return; const userMsgId = `msg-${Date.now()}`; const assistantMsgId = `msg-${Date.now() + 1}`; const userMsg: ChatMessage = { id: userMsgId, role: 'user', content: input, timestamp: new Date().toISOString(), image_url: imagePreview ?? undefined, }; const assistantMsg: ChatMessage = { id: assistantMsgId, role: 'assistant', content: '', timestamp: new Date().toISOString(), tool_calls: [], }; setMessages(prev => [...prev, userMsg, assistantMsg]); const imgToSend = selectedImage; const contentToSend = input; setInput(''); setSelectedImage(null); setImagePreview(null); setIsStreaming(true); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } await streamChatMessage(patientId, contentToSend, imgToSend, { onText: (chunk) => { setMessages(prev => prev.map(m => m.id === assistantMsgId ? { ...m, content: m.content + chunk } : m ) ); }, onToolStart: (tool, callId) => { setMessages(prev => prev.map(m => m.id === assistantMsgId ? { ...m, tool_calls: [ ...(m.tool_calls ?? []), { id: callId, tool, status: 'calling' as const }, ], } : m ) ); }, onToolResult: (_tool, callId, result) => { setMessages(prev => prev.map(m => m.id === assistantMsgId ? { ...m, tool_calls: (m.tool_calls ?? []).map(tc => tc.id === callId ? { ...tc, status: 'complete' as const, result: result as ToolCall['result'] } : tc ), } : m ) ); }, onDone: () => setIsStreaming(false), onError: (err) => { setMessages(prev => prev.map(m => m.id === assistantMsgId ? { ...m, content: `[ERROR]${err}[/ERROR]` } : m ) ); setIsStreaming(false); }, }); }; const handleClear = async () => { if (!patientId || !confirm('Clear chat history?')) return; await api.clearChat(patientId); setMessages([]); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const handleTextareaChange = (e: React.ChangeEvent) => { setInput(e.target.value); e.target.style.height = 'auto'; e.target.style.height = `${Math.min(e.target.scrollHeight, 160)}px`; }; return (
{/* Header */}
SkinProAI {patient && {patient.name}}
{/* Messages */}
{messages.length === 0 && (

Send a message or attach a skin image to begin analysis.

)} {messages.map(msg => (
{msg.role === 'user' ? (
{msg.image_url && ( Attached )} {msg.content &&

{msg.content}

}
{formatTime(msg.timestamp)}
) : (
{/* Tool call status lines */} {(msg.tool_calls ?? []).map(tc => ( ))} {/* Text content */} {msg.content ? (
) : (!msg.tool_calls || msg.tool_calls.length === 0) && isStreaming ? (
) : null} {(msg.content || (msg.tool_calls && msg.tool_calls.length > 0)) && ( {formatTime(msg.timestamp)} )}
)}
))} {/* Hint after analysis completes */} {!isStreaming && messages.length > 0 && (() => { const last = messages[messages.length - 1]; const hasAnalysis = last.role === 'assistant' && (last.tool_calls ?? []).some(tc => tc.tool === 'analyze_image'); if (!hasAnalysis) return null; return (

Ask a follow-up question, add context, or upload an image to compare to previous.

); })()}
{/* Input bar */}