409 lines
8.1 KiB
Markdown
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`
|