winncare / frontend /src /components /ChatView.tsx
esra2001's picture
fix language confusion and hallucinations: centralized resolver + deterministic abstention
3999ba0
Raw
History Blame Contribute Delete
5.74 kB
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>
)
}