feat: Phase 5 - citations, source highlighting, and UI polish

This commit is contained in:
2026-04-13 15:47:47 -04:00
parent e77fa69b31
commit 732555cf55
8 changed files with 381 additions and 103 deletions

View File

@@ -2,7 +2,8 @@ import { useState, useRef, useEffect } from 'react'
import './App.css'
import MessageList from './components/MessageList'
import ChatInput from './components/ChatInput'
import { useChatStream } from './hooks/useChatStream'
import CitationsPanel from './components/CitationsPanel'
import { useChatStream, Citation } from './hooks/useChatStream'
export interface Message {
role: 'user' | 'assistant'
@@ -13,15 +14,13 @@ function App() {
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [citations, setCitations] = useState<Citation[]>([])
const [showCitations, setShowCitations] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const { sendMessage } = useChatStream()
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
useEffect(() => {
scrollToBottom()
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const handleSend = async () => {
@@ -31,24 +30,37 @@ function App() {
setInput('')
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
setIsLoading(true)
setCitations([])
setShowCitations(false)
let assistantContent = ''
await sendMessage(userMessage, (chunk) => {
assistantContent += chunk
setMessages(prev => {
const newMessages = [...prev]
const lastMsg = newMessages[newMessages.length - 1]
if (lastMsg?.role === 'assistant') {
lastMsg.content = assistantContent
} else {
newMessages.push({ role: 'assistant', content: assistantContent })
}
return newMessages
})
await sendMessage(userMessage, {
onChunk: (chunk) => {
assistantContent += chunk
setMessages(prev => {
const newMessages = [...prev]
const lastMsg = newMessages[newMessages.length - 1]
if (lastMsg?.role === 'assistant') {
lastMsg.content = assistantContent
} else {
newMessages.push({ role: 'assistant', content: assistantContent })
}
return newMessages
})
},
onCitations: (newCitations) => {
setCitations(newCitations)
setShowCitations(newCitations.length > 0)
},
onDone: () => {
setIsLoading(false)
},
onError: (error) => {
console.error('Stream error:', error)
setIsLoading(false)
}
})
setIsLoading(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -76,6 +88,11 @@ function App() {
disabled={isLoading}
/>
</footer>
<CitationsPanel
citations={citations}
isOpen={showCitations}
onClose={() => setShowCitations(false)}
/>
</div>
)
}

View File

@@ -0,0 +1,124 @@
/* CitationsPanel.css */
.citations-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
border: none;
width: 100%;
cursor: pointer;
}
.citations-panel {
position: fixed;
top: 0;
right: 0;
width: 400px;
height: 100vh;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
z-index: 101;
display: flex;
flex-direction: column;
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.citations-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.citations-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.close-button {
background: none;
border: none;
color: var(--text-secondary);
font-size: 24px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.close-button:hover {
color: var(--text-primary);
}
.citations-list {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.citation-item {
display: flex;
gap: 12px;
padding: 16px;
margin-bottom: 12px;
background: var(--bg-tertiary);
border-radius: 8px;
border: 1px solid var(--border);
}
.citation-number {
flex-shrink: 0;
font-weight: 600;
color: var(--accent-primary);
font-size: 14px;
}
.citation-content {
flex: 1;
min-width: 0;
}
.citation-source {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 8px;
word-break: break-all;
}
.citation-text {
font-size: 14px;
color: var(--text-primary);
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}
.citation-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.citation-tag {
font-size: 11px;
padding: 2px 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: 4px;
border: 1px solid var(--border);
}

View File

@@ -0,0 +1,47 @@
import { Citation } from '../hooks/useChatStream'
import './CitationsPanel.css'
interface CitationsPanelProps {
citations: Citation[]
isOpen: boolean
onClose: () => void
}
export default function CitationsPanel({ citations, isOpen, onClose }: CitationsPanelProps) {
if (!isOpen || citations.length === 0) return null
return (
<>
<button
type="button"
className="citations-overlay"
onClick={onClose}
aria-label="Close citations panel"
/>
<aside className="citations-panel">
<div className="citations-header">
<h3>Sources</h3>
<button type="button" className="close-button" onClick={onClose}>×</button>
</div>
<div className="citations-list">
{citations.map((citation, index) => (
<div key={citation.id} className="citation-item">
<div className="citation-number">[{index + 1}]</div>
<div className="citation-content">
<div className="citation-source">{citation.citation}</div>
<div className="citation-text">{citation.text}</div>
{citation.tags && citation.tags.length > 0 && (
<div className="citation-tags">
{citation.tags.map(tag => (
<span key={tag} className="citation-tag">{tag}</span>
))}
</div>
)}
</div>
</div>
))}
</div>
</aside>
</>
)
}

View File

@@ -2,13 +2,33 @@ import { useState } from 'react'
const API_BASE = '/api'
export interface Citation {
id: string
text: string
source_file: string
source_directory: string
section: string | null
date: string | null
tags: string[]
citation: string
}
export interface StreamCallbacks {
onChunk: (chunk: string) => void
onCitations?: (citations: Citation[]) => void
onError?: (error: string) => void
onDone?: () => void
}
export function useChatStream() {
const [sessionId, setSessionId] = useState<string | null>(null)
const sendMessage = async (
message: string,
onChunk: (chunk: string) => void
callbacks: StreamCallbacks
): Promise<void> => {
const { onChunk, onCitations, onError, onDone } = callbacks
const response = await fetch(`${API_BASE}/chat`, {
method: 'POST',
headers: {
@@ -18,6 +38,7 @@ export function useChatStream() {
message,
session_id: sessionId,
stream: true,
use_rag: true,
}),
})
@@ -50,15 +71,33 @@ export function useChatStream() {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') {
onDone?.()
return
}
try {
const parsed = JSON.parse(data)
if (parsed.content) {
onChunk(parsed.content)
switch (parsed.type) {
case 'chunk':
if (parsed.content) {
onChunk(parsed.content)
}
break
case 'citations':
if (parsed.citations && onCitations) {
onCitations(parsed.citations as Citation[])
}
break
case 'error':
if (parsed.message && onError) {
onError(parsed.message)
}
break
case 'done':
onDone?.()
return
}
} catch {
// Ignore parse errors
// Ignore parse errors for non-JSON data
}
}
}