Add RAG Phase 4 management guidance, rebuild guidelines index (286 chunks), post-analysis hint UI
5241b71 | 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<Patient | null>(null); | |
| const [messages, setMessages] = useState<ChatMessage[]>([]); | |
| const [input, setInput] = useState(''); | |
| const [selectedImage, setSelectedImage] = useState<File | null>(null); | |
| const [imagePreview, setImagePreview] = useState<string | null>(null); | |
| const [isStreaming, setIsStreaming] = useState(false); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSend(); | |
| } | |
| }; | |
| const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { | |
| setInput(e.target.value); | |
| e.target.style.height = 'auto'; | |
| e.target.style.height = `${Math.min(e.target.scrollHeight, 160)}px`; | |
| }; | |
| return ( | |
| <div className="chat-page"> | |
| {/* Header */} | |
| <header className="chat-header"> | |
| <button className="header-back-btn" onClick={() => navigate('/')} title="Back to patients"> | |
| <svg viewBox="0 0 24 24" fill="currentColor"> | |
| <path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" /> | |
| </svg> | |
| </button> | |
| <div className="header-center"> | |
| <span className="header-app-name">SkinProAI</span> | |
| {patient && <span className="header-patient-name">{patient.name}</span>} | |
| </div> | |
| <button className="header-clear-btn" onClick={handleClear} title="Clear history"> | |
| Clear | |
| </button> | |
| </header> | |
| {/* Messages */} | |
| <main className="chat-messages"> | |
| {messages.length === 0 && ( | |
| <div className="chat-empty"> | |
| <div className="chat-empty-icon"> | |
| <svg viewBox="0 0 24 24" fill="currentColor"> | |
| <path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z" /> | |
| </svg> | |
| </div> | |
| <p>Send a message or attach a skin image to begin analysis.</p> | |
| </div> | |
| )} | |
| {messages.map(msg => ( | |
| <div key={msg.id} className={`message-row ${msg.role}`}> | |
| {msg.role === 'user' ? ( | |
| <div className="user-message"> | |
| <div className="user-bubble"> | |
| {msg.image_url && ( | |
| <img src={msg.image_url} className="message-image" alt="Attached" /> | |
| )} | |
| {msg.content && <p className="bubble-text">{msg.content}</p>} | |
| </div> | |
| <span className="msg-time">{formatTime(msg.timestamp)}</span> | |
| </div> | |
| ) : ( | |
| <div className="assistant-message"> | |
| {/* Tool call status lines */} | |
| {(msg.tool_calls ?? []).map(tc => ( | |
| <ToolCallCard key={tc.id} toolCall={tc} /> | |
| ))} | |
| {/* Text content */} | |
| {msg.content ? ( | |
| <div className="assistant-text"> | |
| <MessageContent text={msg.content} /> | |
| </div> | |
| ) : (!msg.tool_calls || msg.tool_calls.length === 0) && isStreaming ? ( | |
| <div className="thinking"> | |
| <span className="dot" /> | |
| <span className="dot" /> | |
| <span className="dot" /> | |
| </div> | |
| ) : null} | |
| {(msg.content || (msg.tool_calls && msg.tool_calls.length > 0)) && ( | |
| <span className="msg-time">{formatTime(msg.timestamp)}</span> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| {/* 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 ( | |
| <p className="post-analysis-hint"> | |
| Ask a follow-up question, add context, or upload an image to compare to previous. | |
| </p> | |
| ); | |
| })()} | |
| <div ref={messagesEndRef} /> | |
| </main> | |
| {/* Input bar */} | |
| <footer className="chat-input-bar"> | |
| {imagePreview && ( | |
| <div className="image-preview-container"> | |
| <img src={imagePreview} alt="Preview" className="image-preview-thumb" /> | |
| <button | |
| className="remove-image-btn" | |
| onClick={() => { setSelectedImage(null); setImagePreview(null); }} | |
| title="Remove image" | |
| > | |
| × | |
| </button> | |
| </div> | |
| )} | |
| <div className="input-row"> | |
| <button | |
| className="attach-btn" | |
| onClick={() => fileInputRef.current?.click()} | |
| title="Attach image" | |
| disabled={isStreaming} | |
| > | |
| <svg viewBox="0 0 24 24" fill="currentColor"> | |
| <path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5a2.5 2.5 0 015 0v10.5c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5V6H10v9.5a2.5 2.5 0 005 0V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" /> | |
| </svg> | |
| </button> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept="image/*" | |
| style={{ display: 'none' }} | |
| onChange={e => e.target.files?.[0] && handleImageSelect(e.target.files[0])} | |
| /> | |
| <textarea | |
| ref={textareaRef} | |
| className="chat-input" | |
| placeholder="Type a message..." | |
| value={input} | |
| onChange={handleTextareaChange} | |
| onKeyDown={handleKeyDown} | |
| disabled={isStreaming} | |
| rows={1} | |
| /> | |
| <button | |
| className="send-btn" | |
| onClick={handleSend} | |
| disabled={isStreaming || (!input.trim() && !selectedImage)} | |
| title="Send" | |
| > | |
| <svg viewBox="0 0 24 24" fill="currentColor"> | |
| <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" /> | |
| </svg> | |
| </button> | |
| </div> | |
| </footer> | |
| </div> | |
| ); | |
| } | |