SkinProAI / web /src /pages /ChatPage.tsx
cgoodmaker's picture
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>
);
}