Spaces:
Sleeping
Sleeping
| import { useState, useRef, useEffect, KeyboardEvent, Dispatch, SetStateAction } from 'react' | |
| import ReactMarkdown from 'react-markdown' | |
| import remarkGfm from 'remark-gfm' | |
| import { askQuestionStream } from '../api' | |
| import type { Citation, Document, HistoryMessage } from '../api' | |
| import type { Message } from '../types' | |
| import type { Tr } from '../i18n' | |
| import styles from './ChatView.module.css' | |
| const STATUS_LABELS: Record<string, string> = { | |
| searching: 'Recherche dans les documents…', | |
| querying: 'Analyse des données…', | |
| generating: 'Rédaction de la réponse…', | |
| } | |
| function formatCitation(c: Citation): string { | |
| const page = c.page | |
| if (typeof page === 'string') return `${c.filename} · ${page}` | |
| if (page > 0) return `${c.filename} p.${page}` | |
| return c.filename | |
| } | |
| interface Props { | |
| documents: Document[] | |
| messages: Message[] | |
| setMessages: Dispatch<SetStateAction<Message[]>> | |
| onSave?: (msgs: Message[]) => void | |
| tr: Tr | |
| lang: string | |
| } | |
| export default function ChatView({ documents, messages, setMessages, onSave, tr, lang }: Props) { | |
| const [input, setInput] = useState('') | |
| const [loading, setLoading] = useState(false) | |
| const [streamStatus, setStreamStatus] = useState<string>('') | |
| const bottomRef = useRef<HTMLDivElement>(null) | |
| useEffect(() => { | |
| bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) | |
| }, [messages, loading]) | |
| const send = async () => { | |
| const q = input.trim() | |
| if (!q || loading) return | |
| setInput('') | |
| setStreamStatus('') | |
| const userMsg: Message = { role: 'user', text: q } | |
| setMessages(prev => [...prev, userMsg]) | |
| setLoading(true) | |
| const history: HistoryMessage[] = messages.map(m => ({ role: m.role, content: m.text })) | |
| setMessages(prev => [...prev, { role: 'assistant', text: '' }]) | |
| try { | |
| await askQuestionStream( | |
| q, | |
| history, | |
| 6, | |
| '', | |
| (token) => { | |
| setStreamStatus('') | |
| setMessages(prev => { | |
| const copy = [...prev] | |
| copy[copy.length - 1] = { | |
| ...copy[copy.length - 1], | |
| text: copy[copy.length - 1].text + token, | |
| } | |
| return copy | |
| }) | |
| }, | |
| (meta) => { | |
| setStreamStatus('') | |
| setMessages(prev => { | |
| const copy = [...prev] | |
| const last = copy[copy.length - 1] | |
| const text = meta.path === 'sql' && meta.answer ? meta.answer : last.text | |
| copy[copy.length - 1] = { ...last, text, meta } | |
| onSave?.(copy) | |
| return copy | |
| }) | |
| }, | |
| (err) => { | |
| setStreamStatus('') | |
| setMessages(prev => { | |
| const copy = [...prev] | |
| copy[copy.length - 1] = { role: 'assistant', text: `Error: ${err}` } | |
| return copy | |
| }) | |
| }, | |
| (s) => setStreamStatus(s), | |
| lang, | |
| ) | |
| } finally { | |
| setLoading(false) | |
| setStreamStatus('') | |
| } | |
| } | |
| const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault() | |
| send() | |
| } | |
| } | |
| const empty = documents.length === 0 | |
| return ( | |
| <div className={styles.root}> | |
| <div className={styles.toolbar}> | |
| <span className={styles.docCount}> | |
| {tr.docCount(documents.length)} | |
| </span> | |
| {messages.length > 0 && ( | |
| <button className={styles.clearBtn} onClick={() => setMessages([])}> | |
| {tr.clearChat} | |
| </button> | |
| )} | |
| </div> | |
| <div className={styles.thread}> | |
| {messages.length === 0 && ( | |
| <div className={styles.empty}> | |
| {empty ? tr.emptyNoDoc : tr.emptyWithDoc} | |
| </div> | |
| )} | |
| {messages.map((m, i) => ( | |
| <div key={i} className={`${styles.msg} ${styles[m.role]}`}> | |
| {m.role === 'assistant' && <span className={styles.senderName}>Julia</span>} | |
| {m.role === 'assistant' ? ( | |
| loading && i === messages.length - 1 && m.text === '' ? ( | |
| <span className={styles.thinking}> | |
| {streamStatus ? (STATUS_LABELS[streamStatus] ?? tr.thinking) : tr.thinking} | |
| </span> | |
| ) : ( | |
| <div className={styles.markdown}> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]}>{m.text}</ReactMarkdown> | |
| </div> | |
| ) | |
| ) : ( | |
| <p className={styles.text}>{m.text}</p> | |
| )} | |
| {m.meta && ( | |
| <div className={styles.meta}> | |
| {m.meta.citations.length > 0 && ( | |
| <div className={styles.citations}> | |
| {m.meta.citations.map((c, j) => ( | |
| <span key={j} className={styles.citation} title={c.snippet ?? ''}> | |
| {formatCitation(c)} | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| <span className={styles.pathBadge} data-path={m.meta.path}> | |
| {m.meta.path === 'sql' ? 'SQL' : 'Vector'} · {m.meta.chunks_used} chunks | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| <div ref={bottomRef} /> | |
| </div> | |
| <div className={styles.inputRow}> | |
| <textarea | |
| className={styles.textarea} | |
| rows={2} | |
| placeholder={tr.askPlaceholder} | |
| value={input} | |
| onChange={e => setInput(e.target.value)} | |
| onKeyDown={onKey} | |
| disabled={loading} | |
| /> | |
| <button | |
| className={styles.sendBtn} | |
| onClick={send} | |
| disabled={loading || !input.trim()} | |
| > | |
| {tr.send} | |
| </button> | |
| </div> | |
| </div> | |
| ) | |
| } | |