Files
kv-ai/docs/ui.md

8.1 KiB

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:

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:

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:

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:

interface UseChatStreamReturn {
  sendMessage: (
    message: string,
    onChunk: (chunk: string) => void
  ) => Promise<void>
  sessionId: string | null
}

const { sendMessage, sessionId } = useChatStream()

Usage:

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:

: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:

@keyframes bounce {
  0%, 80%, 100% { transform: scale(0.6); }
  40% { transform: scale(1); }
}

Development

Setup

cd ui
npm install

Dev Server

npm run dev
# Opens http://localhost:5173

Build

npm run build
# Output: ui/dist/

Preview Production Build

npm run preview

Configuration

Vite Config

vite.config.ts:

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:

const API_BASE = '/api'  // Not http://localhost:7373/api

TypeScript

Types

// 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

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:

/* Custom accent color */
--accent-primary: #ff6b6b;
--user-bg: #ff6b6b;

Fonts

Update index.css:

body {
  font-family: 'Inter', -apple-system, sans-serif;
}

Message Layout

Modify MessageList.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