Spaces:
Sleeping
Sleeping
File size: 7,730 Bytes
d82d893 f1b7f9a 63428e7 98d2bd7 d82d893 f9f3f42 8d0b379 98d2bd7 63428e7 8d0b379 98d2bd7 f9f3f42 63428e7 d82d893 63428e7 d82d893 d38750a d82d893 f444dc0 d82d893 f444dc0 d82d893 f444dc0 d82d893 f444dc0 98d2bd7 d82d893 f444dc0 d82d893 63428e7 d82d893 63428e7 d82d893 f444dc0 98d2bd7 f444dc0 98d2bd7 f444dc0 63428e7 f444dc0 d82d893 f444dc0 98d2bd7 f444dc0 8d0b379 d82d893 98d2bd7 d82d893 98d2bd7 f9f3f42 8d0b379 f9f3f42 98d2bd7 f9f3f42 98d2bd7 63428e7 98d2bd7 8d0b379 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
import { useState, useEffect, useRef, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import { getChatMarkdownComponents } from '../utils/markdownComponents.jsx';
const SimpleChat = ({ messages, currentChunkIndex, onSend, isLoading }) => {
const [input, setInput] = useState('');
const containerRef = useRef(null);
const anchorRef = useRef(null); // <- will be a tiny zero-height anchor BEFORE the bubble
const textareaRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
if (!input.trim() || isLoading ) return;
onSend(input.trim());
setInput('');
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px';
}
}, [input]);
// Since messages are now filtered to current chunk only, use the last message for anchoring
const { anchorIndex, firstInChunkIndex } = useMemo(() => {
const lastIndex = messages.length > 0 ? messages.length - 1 : -1;
const firstIndex = messages.length > 0 ? 0 : -1;
return { anchorIndex: lastIndex, firstInChunkIndex: firstIndex };
}, [messages]);
// Scroll by scrolling the ZERO-HEIGHT anchor into view AFTER layout commits.
const scrollAfterLayout = () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (anchorRef.current && containerRef.current) {
// Calculate position relative to container instead of using scrollIntoView
const anchorTop = anchorRef.current.offsetTop;
containerRef.current.scrollTo({ top: anchorTop, behavior: 'smooth' });
} else if (containerRef.current) {
// fallback: go to top
containerRef.current.scrollTo({ top: 0, behavior: 'smooth' });
}
});
});
};
// When chunk changes, try to pin.
useEffect(() => {
if (anchorIndex !== -1) {
scrollAfterLayout();
} else if (containerRef.current) {
requestAnimationFrame(() => containerRef.current.scrollTo({ top: 0, behavior: 'smooth' }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentChunkIndex, anchorIndex]);
// New messages: pin the new anchor after layout
useEffect(() => {
if (anchorIndex !== -1) scrollAfterLayout();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages.length, anchorIndex]);
return (
<div className="flex flex-col h-full min-h-0">
<div
ref={containerRef}
className="flex-1 min-h-0 overflow-y-auto p-4 flex flex-col space-y-3"
>
{messages.map((message, idx) => {
const isAnchor = idx === anchorIndex;
// Render a zero-height anchor just BEFORE the bubble for the anchor index.
if (isAnchor) {
return (
<div key={idx} className="flex flex-col">
{/* <-- ZERO-HEIGHT anchor: deterministic top-of-message alignment */}
<div ref={anchorRef} style={{ height: 0, margin: 0, padding: 0 }} />
<div className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[90%] p-3 rounded-lg ${
message.role === 'user'
? 'bg-gray-100 text-white'
: 'bg-white text-gray-900'
}`}
>
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeRaw, rehypeKatex]}
components={getChatMarkdownComponents()}
>
{message.content}
</ReactMarkdown>
</div>
</div>
{isLoading && (
<div className="flex justify-start mt-3">
<div className="bg-gray-100 p-3 rounded-lg">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</div>
</div>
)}
{/* filler to push remaining whitespace below the pinned message */}
<div className="flex-1" />
</div>
);
}
// Non-anchor message: render normally
return (
<div
key={idx}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[90%] p-3 rounded-lg ${
message.role === 'user'
? 'bg-gray-100 text-white'
: 'bg-white text-gray-900'
}`}
>
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeRaw, rehypeKatex]}
components={getChatMarkdownComponents()}
>
{message.content}
</ReactMarkdown>
</div>
</div>
);
})}
{/* if no messages in chunk yet, render typing+filler */}
{firstInChunkIndex === -1 && (
<div className="flex flex-col">
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-100 p-3 rounded-lg">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</div>
</div>
)}
<div className="flex-1" />
</div>
)}
</div>
{/* Input with auto-resize textarea */}
<form onSubmit={handleSubmit} className="p-4 border-t">
<div className="flex space-x-2 items-end">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message... (Enter to send, Shift+Enter for new line)"
disabled={isLoading}
rows={1}
className="flex-1 px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-500 resize-none overflow-hidden"
style={{ minHeight: '42px' }}
/>
<button
type="submit"
disabled={!input.trim() || isLoading}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{isLoading ? '...' : 'Send'}
</button>
</div>
</form>
</div>
);
};
export default SimpleChat;
|