feat: Phase 5 - citations, source highlighting, and UI polish
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
124
ui/src/components/CitationsPanel.css
Normal file
124
ui/src/components/CitationsPanel.css
Normal 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);
|
||||
}
|
||||
47
ui/src/components/CitationsPanel.tsx
Normal file
47
ui/src/components/CitationsPanel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user