cc-rag / frontend /src /components /ChatPanel.tsx
matsuap's picture
feat: implement CC-RAG prototype with Neo4j, FastAPI, React
e675bdb
Raw
History Blame Contribute Delete
6.99 kB
import { useState, useRef, useEffect } from 'react'
import { Send, ChevronDown, ChevronRight } from 'lucide-react'
import { api } from '../api'
import type { AppSettings, ChatResponse, CausalChain } from '../types'
interface Props {
documentId: string
settings: AppSettings
}
interface Message {
role: 'user' | 'assistant'
content: string
response?: ChatResponse
}
function ChainCard({ chain, index }: { chain: CausalChain; index: number }) {
const [open, setOpen] = useState(index === 0)
return (
<div style={{
border: '1px solid var(--border)', borderRadius: 'var(--radius)',
marginBottom: 8, overflow: 'hidden',
}}>
<button
onClick={() => setOpen(!open)}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 12px', background: 'var(--bg3)', border: 'none',
color: 'var(--text)', cursor: 'pointer', textAlign: 'left', fontSize: 12,
}}
>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span style={{ fontWeight: 600, color: chain.score > 0.6 ? 'var(--success)' : 'var(--muted)' }}>
Score: {chain.score.toFixed(2)}
</span>
<span style={{
fontSize: 11, padding: '1px 6px', borderRadius: 99,
background: chain.direction === 'backward' ? 'rgba(239,68,68,0.15)' : 'rgba(96,165,250,0.15)',
color: chain.direction === 'backward' ? '#f87171' : '#60a5fa',
}}>
{chain.direction}
</span>
<span style={{ color: 'var(--muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{chain.path.join(' → ')}
</span>
</button>
{open && (
<div style={{ padding: 12, fontSize: 12 }}>
<div style={{ marginBottom: 10, fontFamily: 'monospace', color: 'var(--accent2)', wordBreak: 'break-word' }}>
{chain.path.map((node, i) => (
<span key={i}>
<span style={{ padding: '2px 6px', background: 'rgba(99,102,241,0.15)', borderRadius: 4 }}>{node}</span>
{i < chain.path.length - 1 && <span style={{ color: 'var(--muted)', margin: '0 4px' }}></span>}
</span>
))}
</div>
{chain.edges.map((edge, i) => (
<div key={i} style={{
borderLeft: '2px solid var(--border)', paddingLeft: 10, marginBottom: 8,
}}>
<div style={{ marginBottom: 2 }}>
<span style={{ color: '#f87171' }}>{edge.cause}</span>
<span style={{ margin: '0 6px' }}>
<span className={`badge ${edge.relation}`}>{edge.relation}</span>
</span>
<span style={{ color: '#60a5fa' }}>{edge.effect}</span>
<span style={{ color: 'var(--muted)', marginLeft: 8 }}>[{edge.confidence.toFixed(2)}]</span>
</div>
{edge.evidence && (
<div style={{ color: 'var(--muted)', fontStyle: 'italic', fontSize: 11 }}>
"{edge.evidence}"
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
export function ChatPanel({ documentId, settings }: Props) {
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const send = async () => {
const msg = input.trim()
if (!msg || loading) return
setInput('')
setError(null)
setMessages(prev => [...prev, { role: 'user', content: msg }])
setLoading(true)
try {
const res = await api.chat(documentId, msg, settings)
setMessages(prev => [...prev, { role: 'assistant', content: res.answer, response: res }])
} catch (e: any) {
setError(String(e.message))
} finally {
setLoading(false)
}
}
const SAMPLES = [
'Why did quarterly revenue decrease?',
'What caused inventory shortages?',
'What are the downstream effects of port congestion?',
]
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', maxWidth: 900 }}>
{messages.length === 0 && (
<div style={{ marginBottom: 16 }}>
<div className="label" style={{ marginBottom: 8 }}>Sample Questions</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{SAMPLES.map(q => (
<button key={q} className="btn-secondary" style={{ fontSize: 12 }} onClick={() => setInput(q)}>
{q}
</button>
))}
</div>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16, marginBottom: 16 }}>
{messages.map((m, i) => (
<div key={i}>
{m.role === 'user' ? (
<div style={{
background: 'var(--accent)', borderRadius: 'var(--radius)',
padding: '10px 14px', alignSelf: 'flex-end', fontSize: 14,
maxWidth: '70%', marginLeft: 'auto',
}}>
{m.content}
</div>
) : (
<div>
<div className="card" style={{ marginBottom: 12, fontSize: 14, lineHeight: 1.6 }}>
{m.content}
</div>
{m.response && m.response.chains.length > 0 && (
<div>
<div className="label" style={{ marginBottom: 8 }}>
Retrieved Causal Chains ({m.response.chains.length})
</div>
{m.response.chains.map((chain, ci) => (
<ChainCard key={ci} chain={chain} index={ci} />
))}
</div>
)}
</div>
)}
</div>
))}
{loading && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--muted)', fontSize: 13 }}>
<span className="spinner" style={{ width: 16, height: 16 }} />
Retrieving causal chains...
</div>
)}
{error && <div className="error-msg">{error}</div>}
<div ref={bottomRef} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
placeholder="Ask about causal relationships..."
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && send()}
disabled={loading}
style={{ flex: 1 }}
/>
<button className="btn-primary" onClick={send} disabled={loading || !input.trim()}>
<Send size={16} />
</button>
</div>
</div>
)
}