Spaces:
Sleeping
Sleeping
| import { useState, useRef, FormEvent } from 'react'; | |
| import { StreamingText } from './StreamingText'; | |
| interface Message { | |
| role: 'user' | 'assistant'; | |
| content: string; | |
| } | |
| export default function App() { | |
| const [messages, setMessages] = useState<Message[]>([]); | |
| const [input, setInput] = useState(''); | |
| const [isStreaming, setIsStreaming] = useState(false); | |
| const abortControllerRef = useRef<AbortController | null>(null); | |
| const handleSubmit = async (e: FormEvent) => { | |
| e.preventDefault(); | |
| if (!input.trim() || isStreaming) return; | |
| const userMessage: Message = { role: 'user', content: input }; | |
| const newMessages = [...messages, userMessage]; | |
| setMessages(newMessages); | |
| setInput(''); | |
| setIsStreaming(true); | |
| setMessages((prev) => [...prev, { role: 'assistant', content: '' }]); | |
| abortControllerRef.current = new AbortController(); | |
| try { | |
| const response = await fetch('/api/chat', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ messages: newMessages }), | |
| signal: abortControllerRef.current.signal, | |
| }); | |
| if (!response.body) throw new Error('No body in response'); | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| // Robust SSE parser: handles chunks split across network packets | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop() || ''; | |
| for (const line of lines) { | |
| if (line.startsWith('data:')) { | |
| const jsonStr = line.replace('data:', '').trim(); | |
| if (jsonStr === '[DONE]') continue; | |
| try { | |
| const parsed = JSON.parse(jsonStr); | |
| // OpenAI-compatible SSE: choices[0].delta.content | |
| const token: string = parsed.choices?.[0]?.delta?.content ?? ''; | |
| if (token) { | |
| setMessages((prev) => { | |
| const updated = [...prev]; | |
| const lastMsg = updated[updated.length - 1]; | |
| if (lastMsg.role === 'assistant') { | |
| lastMsg.content += token; | |
| } | |
| return updated; | |
| }); | |
| } | |
| } catch (err) { | |
| console.error('JSON parse error on line:', line, err); | |
| } | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| if ((error as Error).name === 'AbortError') { | |
| console.log('Stream aborted by user'); | |
| } else { | |
| console.error('Chat error:', error); | |
| } | |
| } finally { | |
| setIsStreaming(false); | |
| abortControllerRef.current = null; | |
| } | |
| }; | |
| const handleStop = () => { | |
| abortControllerRef.current?.abort(); | |
| }; | |
| return ( | |
| <div style={{ height: '100vh', display: 'flex', flexDirection: 'column', backgroundColor: '#f5f5f5' }}> | |
| <header | |
| style={{ | |
| padding: '1rem', | |
| backgroundColor: '#fff', | |
| borderBottom: '1px solid #ddd', | |
| boxShadow: '0 2px 4px rgba(0,0,0,0.1)', | |
| }} | |
| > | |
| <h1 style={{ margin: 0, fontSize: '1.25rem' }}>Qwen 2.5 7B Chat</h1> | |
| </header> | |
| <main style={{ flex: 1, overflowY: 'auto', padding: '1rem' }}> | |
| {messages.map((msg, idx) => ( | |
| <div | |
| key={idx} | |
| style={{ | |
| marginBottom: '1rem', | |
| display: 'flex', | |
| justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start', | |
| }} | |
| > | |
| <div | |
| style={{ | |
| maxWidth: '70%', | |
| padding: '0.75rem', | |
| borderRadius: '12px', | |
| backgroundColor: msg.role === 'user' ? '#007bff' : '#fff', | |
| color: msg.role === 'user' ? '#fff' : '#000', | |
| boxShadow: '0 1px 2px rgba(0,0,0,0.1)', | |
| }} | |
| > | |
| {msg.role === 'assistant' && idx === messages.length - 1 && isStreaming ? ( | |
| <StreamingText content={msg.content} /> | |
| ) : ( | |
| <div style={{ whiteSpace: 'pre-wrap' }}>{msg.content}</div> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </main> | |
| <footer style={{ padding: '1rem', backgroundColor: '#fff', borderTop: '1px solid #ddd' }}> | |
| <form onSubmit={handleSubmit} style={{ display: 'flex', gap: '0.5rem' }}> | |
| <input | |
| type="text" | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| placeholder="Type your message..." | |
| style={{ | |
| flex: 1, | |
| padding: '0.75rem', | |
| borderRadius: '4px', | |
| border: '1px solid #ccc', | |
| }} | |
| disabled={isStreaming} | |
| /> | |
| {isStreaming ? ( | |
| <button | |
| type="button" | |
| onClick={handleStop} | |
| style={{ | |
| padding: '0.75rem 1.5rem', | |
| backgroundColor: '#dc3545', | |
| color: '#fff', | |
| border: 'none', | |
| borderRadius: '4px', | |
| cursor: 'pointer', | |
| }} | |
| > | |
| Stop | |
| </button> | |
| ) : ( | |
| <button | |
| type="submit" | |
| disabled={!input.trim()} | |
| style={{ | |
| padding: '0.75rem 1.5rem', | |
| backgroundColor: '#007bff', | |
| color: '#fff', | |
| border: 'none', | |
| borderRadius: '4px', | |
| cursor: 'pointer', | |
| }} | |
| > | |
| Send | |
| </button> | |
| )} | |
| </form> | |
| </footer> | |
| </div> | |
| ); | |
| } | |