| import { useState, useRef, useEffect, useCallback } from 'react'; |
| import ChatInput from './ChatInput'; |
| import { api, streamChat } from '../api'; |
|
|
| function renderMarkdown(text) { |
| let html = text |
| .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') |
| .replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>') |
| .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') |
| .replace(/\*(.+?)\*/g, '<em>$1</em>') |
| .replace(/`([^`]+)`/g, '<code>$1</code>') |
| .replace(/^### (.+)$/gm, '<h3>$1</h3>') |
| .replace(/^## (.+)$/gm, '<h2>$1</h2>') |
| .replace(/^# (.+)$/gm, '<h1>$1</h1>') |
| .replace(/^[\-*] (.+)$/gm, '<li>$1</li>') |
| .replace(/^\d+\. (.+)$/gm, '<li>$1</li>') |
| .replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>') |
| .replace(/^---$/gm, '<hr>') |
| .replace(/\n\n/g, '</p><p>'); |
| html = '<p>' + html + '</p>'; |
| html = html.replace(/<\/li>\s*<li>/g, '</li><li>'); |
| html = html.replace(/(<li>.*?<\/li>)/gs, m => `<ul>${m}</ul>`); |
| html = html.replace(/<\/ul>\s*<ul>/g, ''); |
| html = html.replace(/<p><\/p>/g, ''); |
| return html; |
| } |
|
|
| function stripMd(t) { |
| return t.replace(/<[^>]*>/g, '').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\*([^*]+)\*/g, '$1') |
| .replace(/`([^`]+)`/g, '$1').replace(/^#{1,6}\s+/gm, '').replace(/^[-*]\s+/gm, '').replace(/^>\s+/gm, '') |
| .replace(/\n{2,}/g, '. ').replace(/\n/g, ' ').trim(); |
| } |
|
|
| function MessageBubble({ role, content, isStreaming }) { |
| return ( |
| <div className={`msg ${role}`}> |
| <div className="msg-avatar">{role === 'user' ? '👤' : '⚡'}</div> |
| <div className="msg-body"> |
| <div className={`msg-content${isStreaming ? ' streaming-cursor' : ''}`} |
| dangerouslySetInnerHTML={{ __html: content ? renderMarkdown(content) : '' }} /> |
| <div className="msg-time">{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| export default function ChatPanel({ sessionId, ensureSession, setSessionId }) { |
| const [messages, setMessages] = useState([]); |
| const [streaming, setStreaming] = useState(false); |
| const [welcome, setWelcome] = useState(true); |
| const [loading, setLoading] = useState(false); |
| const messagesEnd = useRef(null); |
| const abortRef = useRef(null); |
| const assistIdxRef = useRef(-1); |
| const sendingRef = useRef(false); |
|
|
| |
| useEffect(() => { |
| |
| |
| if (sendingRef.current) return; |
|
|
| |
| if (abortRef.current) abortRef.current.abort(); |
| setStreaming(false); |
| setMessages([]); |
| setWelcome(true); |
| setLoading(false); |
| assistIdxRef.current = -1; |
|
|
| if (!sessionId) return; |
|
|
| setLoading(true); |
| api.getMessages(sessionId).then(data => { |
| const msgs = (data.messages || []).filter(m => m.role !== 'system'); |
| if (msgs.length) { |
| setMessages(msgs.map(m => ({ role: m.role, content: m.content }))); |
| setWelcome(false); |
| assistIdxRef.current = msgs.length; |
| } |
| }).catch(() => { |
| setMessages([]); |
| setWelcome(true); |
| }).finally(() => setLoading(false)); |
| }, [sessionId]); |
|
|
| useEffect(() => { |
| const el = messagesEnd.current; |
| if (el) { |
| const parent = el.parentElement; |
| const nearBottom = parent.scrollHeight - parent.scrollTop - parent.clientHeight < 80; |
| if (nearBottom) el.scrollIntoView({ behavior: 'smooth' }); |
| } |
| }, [messages]); |
|
|
| const saveRecent = (sid, query) => { |
| const title = query.slice(0, 40) + (query.length > 40 ? '…' : ''); |
| |
| api.updateSessionTitle(sid, title).catch(() => {}); |
| |
| window.dispatchEvent(new CustomEvent('recent-chats-updated')); |
| }; |
|
|
| |
| const send = useCallback(async (text, images = []) => { |
| if (!text.trim() && !images.length) return; |
| console.log('[ChatPanel] send called:', text.slice(0, 50)); |
| sendingRef.current = true; |
| setWelcome(false); |
| const sid = await ensureSession(); |
| console.log('[ChatPanel] session:', sid?.slice(0, 12)); |
| |
| |
| saveRecent(sid, text); |
| const displayText = text || (images.length ? `[Attached: ${images.length} image(s)]` : ''); |
|
|
| |
| setMessages(prev => { |
| assistIdxRef.current = prev.length + 1; |
| console.log('[ChatPanel] adding messages, count:', prev.length, 'assistIdx:', assistIdxRef.current); |
| return [...prev, |
| { role: 'user', content: displayText }, |
| { role: 'assistant', content: '' }, |
| ]; |
| }); |
|
|
| setStreaming(true); |
| abortRef.current = new AbortController(); |
| let fullContent = ''; |
| try { |
| await streamChat(text, sid, images, ({ type, data }) => { |
| if (type === 'text_delta') { |
| fullContent += data.content || data.delta || ''; |
| const idx = assistIdxRef.current; |
| if (!fullContent.trim()) console.log('[ChatPanel] first text_delta, idx:', idx); |
| setMessages(prev => { |
| const next = [...prev]; |
| if (idx < next.length) next[idx] = { ...next[idx], content: fullContent }; |
| return next; |
| }); |
| } else if (type === 'tool_call_start') { |
| setMessages(prev => [...prev, { role: 'tool', content: `🔧 ${data.tool || ''}` }]); |
| } else if (type === 'tool_call_result') { |
| if (data.error) setMessages(prev => [...prev, { role: 'tool', content: `❌ ${data.error}` }]); |
| } else if (type === 'error') { |
| setMessages(prev => [...prev, { role: 'error', content: `⚠️ ${data.error || 'Stream error'}` }]); |
| } else if (type === 'done') { |
| const clean = data.final_answer || ''; |
| if (clean) { |
| const idx = assistIdxRef.current; |
| setMessages(prev => { |
| const next = [...prev]; |
| if (idx < next.length) next[idx] = { ...next[idx], content: clean }; |
| return next; |
| }); |
| } |
| saveRecent(sid, text); |
| } |
| }, abortRef.current.signal); |
| } catch (err) { |
| if (err.name !== 'AbortError') |
| setMessages(prev => [...prev, { role: 'error', content: `Error: ${err.message}` }]); |
| } |
| setStreaming(false); |
| abortRef.current = null; |
| sendingRef.current = false; |
| }, [ensureSession]); |
|
|
| const stop = () => { if (abortRef.current) abortRef.current.abort(); }; |
|
|
| |
| useEffect(() => { |
| let voiceMsgIdx = -1; |
|
|
| const onStart = () => { |
| setWelcome(false); |
| setMessages(prev => { |
| voiceMsgIdx = prev.length + 1; |
| return [...prev, |
| { role: 'user', content: '🎤 ...' }, |
| { role: 'assistant', content: '' }, |
| ]; |
| }); |
| }; |
|
|
| const onTranscript = (e) => { |
| const t = e.detail.text || ''; |
| setMessages(prev => { |
| const next = [...prev]; |
| const userIdx = voiceMsgIdx - 1; |
| if (userIdx >= 0 && userIdx < next.length) next[userIdx] = { ...next[userIdx], content: `🎤 ${t}` }; |
| return next; |
| }); |
| }; |
|
|
| const onDelta = (e) => { |
| setMessages(prev => { |
| const next = [...prev]; |
| if (voiceMsgIdx >= 0 && voiceMsgIdx < next.length) { |
| next[voiceMsgIdx] = { ...next[voiceMsgIdx], content: next[voiceMsgIdx].content + (e.detail.content || '') }; |
| } |
| return next; |
| }); |
| }; |
|
|
| const onTool = (e) => { |
| setMessages(prev => [...prev, { role: 'tool', content: `🔧 ${e.detail.tool || ''}` }]); |
| }; |
|
|
| const onAudio = (e) => { |
| const { audio, format } = e.detail; |
| if (!audio) return; |
| try { |
| const binary = atob(audio); |
| const bytes = new Uint8Array(binary.length); |
| for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); |
| const blob = new Blob([bytes], { type: format === 'mp3' ? 'audio/mpeg' : 'audio/wav' }); |
| new Audio(URL.createObjectURL(blob)).play().catch(() => {}); |
| } catch {} |
| }; |
|
|
| const onDone = () => { voiceMsgIdx = -1; }; |
|
|
| const onError = (e) => { |
| setMessages(prev => [...prev, { role: 'error', content: `⚠️ ${e.detail.error || 'Voice failed'}` }]); |
| voiceMsgIdx = -1; |
| }; |
|
|
| const events = { |
| 'voice-start': onStart, 'voice-transcript': onTranscript, |
| 'voice-delta': onDelta, 'voice-tool': onTool, |
| 'voice-audio': onAudio, 'voice-done': onDone, 'voice-error': onError, |
| }; |
| Object.entries(events).forEach(([name, fn]) => window.addEventListener(name, fn)); |
| return () => Object.entries(events).forEach(([name, fn]) => window.removeEventListener(name, fn)); |
| }, []); |
|
|
| return ( |
| <div className={`chat-panel${welcome && !loading ? ' welcome-active' : ''}`}> |
| <div className="chat-messages"> |
| {loading && ( |
| <div className="welcome-block"><div className="welcome-title" style={{fontSize:'1rem',color:'var(--muted-foreground)'}}>Loading…</div></div> |
| )} |
| {!loading && welcome && ( |
| <div className="welcome-block"> |
| <div className="welcome-title">有什么可以帮你的?</div> |
| </div> |
| )} |
| {messages.map((m, i) => |
| m.role === 'tool' || m.role === 'error' ? ( |
| <div key={i} className={`msg-tool ${m.role}`}>{m.content}</div> |
| ) : ( |
| <MessageBubble key={i} role={m.role} content={m.content} isStreaming={streaming && i === assistIdxRef.current} /> |
| ) |
| )} |
| <div ref={messagesEnd} /> |
| </div> |
| <ChatInput onSend={send} streaming={streaming} onStop={stop} sessionId={sessionId} ensureSession={ensureSession} /> |
| </div> |
| ); |
| } |
|
|