Files
kv-ai/docs/ui.md

409 lines
8.1 KiB
Markdown

# UI Module Documentation
The UI is a React + Vite frontend for the companion chat interface. It provides real-time streaming chat with a clean, Obsidian-inspired dark theme.
## Architecture
```
HTTP/SSE
┌─────────────────┐
│ App.tsx │ - State management
│ Message state │ - User/assistant messages
└────────┬────────┘
┌─────────────────┐
│ MessageList │ - Render messages
│ (components/) │ - User/assistant styling
└─────────────────┘
┌─────────────────┐
│ ChatInput │ - Textarea + send
│ (components/) │ - Auto-resize, hotkeys
└─────────────────┘
┌─────────────────┐
│ useChatStream │ - SSE streaming
│ (hooks/) │ - Session management
└─────────────────┘
```
## Project Structure
```
ui/
├── src/
│ ├── main.tsx # React entry point
│ ├── App.tsx # Main app component
│ ├── App.css # App layout styles
│ ├── index.css # Global styles
│ ├── components/
│ │ ├── MessageList.tsx # Message display
│ │ ├── MessageList.css # Message styling
│ │ ├── ChatInput.tsx # Input textarea
│ │ └── ChatInput.css # Input styling
│ └── hooks/
│ └── useChatStream.ts # SSE streaming hook
├── index.html # HTML template
├── vite.config.ts # Vite configuration
├── tsconfig.json # TypeScript config
└── package.json # Dependencies
```
## Components
### App.tsx
Main application state management:
```typescript
interface Message {
role: 'user' | 'assistant'
content: string
}
// State
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
// Handlers
const handleSend = async () => { /* ... */ }
const handleKeyDown = (e) => { /* Enter to send, Shift+Enter newline */ }
```
**Features:**
- Auto-scroll to bottom on new messages
- Keyboard shortcuts (Enter to send, Shift+Enter for newline)
- Loading state with animation
- Message streaming in real-time
### MessageList.tsx
Renders the chat history:
```typescript
interface MessageListProps {
messages: Message[]
isLoading: boolean
}
```
**Layout:**
- User messages: Right-aligned, blue background
- Assistant messages: Left-aligned, gray background with border
- Loading indicator: Three animated dots
- Empty state: Prompt text when no messages
**Styling:**
- Max-width 800px, centered
- Smooth scroll behavior
- Avatar-less design (clean, text-focused)
### ChatInput.tsx
Textarea input with send button:
```typescript
interface ChatInputProps {
value: string
onChange: (value: string) => void
onSend: () => void
onKeyDown: (e: KeyboardEvent) => void
disabled: boolean
}
```
**Features:**
- Auto-resizing textarea
- Send button with loading state
- Placeholder text
- Disabled during streaming
## Hooks
### useChatStream.ts
Manages SSE streaming connection:
```typescript
interface UseChatStreamReturn {
sendMessage: (
message: string,
onChunk: (chunk: string) => void
) => Promise<void>
sessionId: string | null
}
const { sendMessage, sessionId } = useChatStream()
```
**Usage:**
```typescript
await sendMessage("Hello", (chunk) => {
// Append chunk to current response
setMessages(prev => {
const last = prev[prev.length - 1]
if (last?.role === 'assistant') {
last.content += chunk
return [...prev]
}
return [...prev, { role: 'assistant', content: chunk }]
})
})
```
**SSE Protocol:**
The API streams events in this format:
```
data: {"type": "chunk", "content": "Hello"}
data: {"type": "chunk", "content": " world"}
data: {"type": "sources", "sources": [{"file": "journal.md"}]}
data: {"type": "done", "session_id": "uuid"}
```
## Styling
### Design System
Based on Obsidian's dark theme:
```css
:root {
--bg-primary: #0d1117; /* App background */
--bg-secondary: #161b22; /* Header/footer */
--bg-tertiary: #21262d; /* Input background */
--text-primary: #c9d1d9; /* Main text */
--text-secondary: #8b949e; /* Placeholder */
--accent-primary: #58a6ff; /* Primary blue */
--accent-secondary: #79c0ff;/* Lighter blue */
--border: #30363d; /* Borders */
--user-bg: #1f6feb; /* User message */
--assistant-bg: #21262d; /* Assistant message */
}
```
### Message Styling
**User Message:**
- Blue background (`--user-bg`)
- White text
- Border radius: 12px (12px 12px 4px 12px)
- Max-width: 80%
**Assistant Message:**
- Gray background (`--assistant-bg`)
- Light text (`--text-primary`)
- Border: 1px solid `--border`
- Border radius: 12px (12px 12px 12px 4px)
### Loading Animation
Three bouncing dots using CSS keyframes:
```css
@keyframes bounce {
0%, 80%, 100% { transform: scale(0.6); }
40% { transform: scale(1); }
}
```
## Development
### Setup
```bash
cd ui
npm install
```
### Dev Server
```bash
npm run dev
# Opens http://localhost:5173
```
### Build
```bash
npm run build
# Output: ui/dist/
```
### Preview Production Build
```bash
npm run preview
```
## Configuration
### Vite Config
`vite.config.ts`:
```typescript
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:7373',
changeOrigin: true,
},
},
},
})
```
**Proxy Setup:**
- Frontend: `http://localhost:5173`
- API: `http://localhost:7373`
- `/api/*``http://localhost:7373/api/*`
This allows using relative API paths in the code:
```typescript
const API_BASE = '/api' // Not http://localhost:7373/api
```
## TypeScript
### Types
```typescript
// Message role
type Role = 'user' | 'assistant'
// Message object
interface Message {
role: Role
content: string
}
// Chat request
type ChatRequest = {
message: string
session_id?: string
temperature?: number
}
// SSE chunk
type ChunkEvent = {
type: 'chunk'
content: string
}
type SourcesEvent = {
type: 'sources'
sources: Array<{
file: string
section?: string
date?: string
}>
}
type DoneEvent = {
type: 'done'
session_id: string
}
```
## API Integration
### Chat Endpoint
```typescript
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: userInput,
session_id: sessionId, // null for new session
stream: true,
}),
})
// Read SSE stream
const reader = response.body?.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
// Parse SSE lines
}
```
### Session Persistence
The backend maintains conversation history via `session_id`:
1. First message: `session_id: null` → backend creates UUID
2. Response header: `X-Session-ID: <uuid>`
3. Subsequent messages: include `session_id: <uuid>`
4. History retrieved automatically
## Customization
### Themes
Modify `App.css` and `index.css`:
```css
/* Custom accent color */
--accent-primary: #ff6b6b;
--user-bg: #ff6b6b;
```
### Fonts
Update `index.css`:
```css
body {
font-family: 'Inter', -apple-system, sans-serif;
}
```
### Message Layout
Modify `MessageList.css`:
```css
.message-content {
max-width: 90%; /* Wider messages */
font-size: 16px; /* Larger text */
}
```
## Troubleshooting
**CORS errors**
- Check `vite.config.ts` proxy configuration
- Verify backend CORS origins include `http://localhost:5173`
**Stream not updating**
- Check browser network tab for SSE events
- Verify `EventSourceResponse` from backend
**Messages not appearing**
- Check React DevTools for state updates
- Verify `messages` array is being mutated correctly
**Build fails**
- Check TypeScript errors: `npx tsc --noEmit`
- Update dependencies: `npm update`