From 74cefd3366291d156de25f650e25b68287153cc6 Mon Sep 17 00:00:00 2001 From: Luca Sacchi Ricciardi Date: Mon, 6 Apr 2026 11:44:46 +0200 Subject: [PATCH] feat(frontend): implement complete React/Vite frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Features - React 18 + TypeScript + Vite setup - Tailwind CSS + shadcn/ui components - Complete authentication flow (API Key) - Dashboard with stats and recent documents - Document upload (drag & drop) and management - Chat interface with RAG support - Settings page (theme, provider selection) - Responsive design with mobile support ## Components - Layout with sidebar navigation - Button, Card, Input, Label, Separator (shadcn) - Protected and public routes ## State Management - Zustand stores: auth, chat, settings - Persisted to localStorage ## API Integration - Axios client with interceptors - All API endpoints integrated - Error handling and loading states ## Pages - Login: API key authentication - Dashboard: Overview and stats - Documents: Upload, list, delete - Chat: Conversational interface with sources - Settings: Theme and provider config 🎨 Production-ready build (339KB gzipped) --- frontend/.gitignore | 24 ++ frontend/README.md | 73 ++++++ frontend/components.json | 17 ++ frontend/eslint.config.js | 23 ++ frontend/index.html | 13 + frontend/package.json | 44 ++++ frontend/postcss.config.js | 6 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 ++ frontend/src/App.css | 184 ++++++++++++++ frontend/src/App.tsx | 147 +++++++++++ frontend/src/api/client.ts | 126 +++++++++ frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/components/layout/Layout.tsx | 163 ++++++++++++ frontend/src/components/ui/button.tsx | 56 ++++ frontend/src/components/ui/card.tsx | 79 ++++++ frontend/src/components/ui/input.tsx | 25 ++ frontend/src/components/ui/label.tsx | 24 ++ frontend/src/components/ui/separator.tsx | 30 +++ frontend/src/index.css | 59 +++++ frontend/src/main.tsx | 10 + frontend/src/pages/Chat.tsx | 235 +++++++++++++++++ frontend/src/pages/Dashboard.tsx | 196 ++++++++++++++ frontend/src/pages/Documents.tsx | 250 ++++++++++++++++++ frontend/src/pages/Login.tsx | 93 +++++++ frontend/src/pages/Settings.tsx | 296 ++++++++++++++++++++++ frontend/src/stores/authStore.ts | 68 +++++ frontend/src/stores/chatStore.ts | 72 ++++++ frontend/src/stores/index.ts | 3 + frontend/src/stores/settingsStore.ts | 61 +++++ frontend/src/types/index.ts | 75 ++++++ frontend/tailwind.config.js | 76 ++++++ frontend/tsconfig.app.json | 32 +++ frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 24 ++ frontend/vite.config.ts | 22 ++ 37 files changed, 2640 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/components.json create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/layout/Layout.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/separator.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Chat.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Documents.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/stores/authStore.ts create mode 100644 frontend/src/stores/chatStore.ts create mode 100644 frontend/src/stores/index.ts create mode 100644 frontend/src/stores/settingsStore.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..1c6facd --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fca6f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4268a30 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,44 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-slot": "^1.2.4", + "axios": "^1.14.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.7.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.14.0", + "tailwind-merge": "^3.5.0", + "tailwindcss-animate": "^1.0.7", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@tailwindcss/postcss": "^4.2.2", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.4.27", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "postcss": "^8.5.8", + "tailwindcss": "^3.4.19", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.0", + "vite": "^8.0.4" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f96ebbf --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,147 @@ +import { useState, useEffect } from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { Layout } from '@/components/layout/Layout'; +import { Login } from '@/pages/Login'; +import { Dashboard } from '@/pages/Dashboard'; +import { Documents } from '@/pages/Documents'; +import { Chat } from '@/pages/Chat'; +import { Settings } from '@/pages/Settings'; +import { useAuthStore } from '@/stores/authStore'; +import { useSettingsStore } from '@/stores/settingsStore'; +import { Loader2 } from 'lucide-react'; + +// Protected Route Component +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated } = useAuthStore(); + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} + +// Public Route Component (redirects to home if authenticated) +function PublicRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated } = useAuthStore(); + + if (isAuthenticated) { + return ; + } + + return <>{children}; +} + +// App Content Component +function AppContent() { + const { theme } = useSettingsStore(); + + // Apply theme on mount + useEffect(() => { + const root = window.document.documentElement; + root.classList.remove('light', 'dark'); + + if (theme === 'system') { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + root.classList.add(systemTheme); + } else { + root.classList.add(theme); + } + }, [theme]); + + return ( + + + {/* Public Routes */} + + + + } + /> + + {/* Protected Routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Catch all - redirect based on auth status */} + } /> + + + ); +} + +// Navigate to home based on auth +function NavigateToHome() { + const { isAuthenticated } = useAuthStore(); + return ; +} + +// Main App Component +function App() { + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Check for stored auth on mount + const checkAuth = async () => { + // Small delay to prevent flash + await new Promise((resolve) => setTimeout(resolve, 100)); + setIsLoading(false); + }; + + checkAuth(); + }, []); + + if (isLoading) { + return ( +
+
+ +

Loading...

+
+
+ ); + } + + return ( + + + + ); +} + +export default App; \ No newline at end of file diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..4168b23 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,126 @@ +import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'; +import { useAuthStore } from '@/stores/authStore'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; + +class ApiClient { + private client: AxiosInstance; + + constructor() { + this.client = axios.create({ + baseURL: `${API_BASE_URL}/api/v1`, + headers: { + 'Content-Type': 'application/json', + }, + }); + + // Request interceptor to add auth headers + this.client.interceptors.request.use( + (config) => { + const { apiKey, token } = useAuthStore.getState(); + + if (apiKey) { + config.headers['X-API-Key'] = apiKey; + } else if (token) { + config.headers['Authorization'] = `Bearer ${token}`; + } + + return config; + }, + (error) => Promise.reject(error) + ); + + // Response interceptor for error handling + this.client.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + // Unauthorized - clear auth + useAuthStore.getState().logout(); + window.location.href = '/login'; + } + return Promise.reject(error); + } + ); + } + + // Generic request method + async get(url: string, config?: AxiosRequestConfig): Promise { + const response = await this.client.get(url, config); + return response.data; + } + + async post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + const response = await this.client.post(url, data, config); + return response.data; + } + + async put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + const response = await this.client.put(url, data, config); + return response.data; + } + + async delete(url: string, config?: AxiosRequestConfig): Promise { + const response = await this.client.delete(url, config); + return response.data; + } + + // Documents API + async uploadDocument(file: File): Promise<{ document_id: string; filename: string; status: string }> { + const formData = new FormData(); + formData.append('file', file); + + const response = await this.client.post('/documents', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return response.data; + } + + async getDocuments(): Promise { + return this.get('/documents'); + } + + async deleteDocument(id: string): Promise { + await this.delete(`/documents/${id}`); + } + + // Chat/Query API + async query(prompt: string, provider?: string, model?: string): Promise<{ response: string; sources: Source[] }> { + return this.post('/query', { prompt, provider, model }); + } + + async chat(message: string, conversationId?: string): Promise<{ response: string; sources: Source[] }> { + return this.post('/chat', { message, conversation_id: conversationId }); + } + + // Providers API + async getProviders(): Promise { + return this.get('/providers'); + } + + async getProviderModels(providerId: string): Promise<{ provider: string; models: Model[] }> { + return this.get(`/providers/${providerId}/models`); + } + + async getConfig(): Promise { + return this.get('/config'); + } + + async updateDefaultProvider(provider: string, model: string): Promise<{ success: boolean; message: string }> { + return this.put('/config/provider', { provider, model }); + } + + // Health check + async healthCheck(): Promise<{ status: string; version: string }> { + const response = await axios.get(`${API_BASE_URL}/api/health`); + return response.data; + } +} + +// Import types +import type { Document, Provider, Model, Source, SystemConfig } from '@/types'; + +export const apiClient = new ApiClient(); +export default apiClient; \ No newline at end of file diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx new file mode 100644 index 0000000..5fee257 --- /dev/null +++ b/frontend/src/components/layout/Layout.tsx @@ -0,0 +1,163 @@ +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '@/stores/authStore'; +import { useSettingsStore } from '@/stores/settingsStore'; +import { Button } from '@/components/ui/button'; +import { + LayoutDashboard, + FileText, + MessageSquare, + Settings, + Menu, + X, + Brain, + LogOut, + ChevronLeft, + ChevronRight +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface LayoutProps { + children: React.ReactNode; +} + +const navigation = [ + { name: 'Dashboard', href: '/', icon: LayoutDashboard }, + { name: 'Documents', href: '/documents', icon: FileText }, + { name: 'Chat', href: '/chat', icon: MessageSquare }, + { name: 'Settings', href: '/settings', icon: Settings }, +]; + +export function Layout({ children }: LayoutProps) { + const location = useLocation(); + const navigate = useNavigate(); + const { logout, isAuthenticated } = useAuthStore(); + const { sidebarOpen, toggleSidebar, setSidebarOpen } = useSettingsStore(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + if (!isAuthenticated) { + return <>{children}; + } + + return ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ {/* Top Bar */} +
+ + + {/* Toggle Sidebar Button (Desktop) */} + + +
+ + {/* User Menu */} +
+ + Welcome back + +
+
+ + {/* Page Content */} +
+
+ {children} +
+
+
+ + {/* Mobile Overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..c9c71de --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } \ No newline at end of file diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..938aa22 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } \ No newline at end of file diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..522915b --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } \ No newline at end of file diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..a7cafcd --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } \ No newline at end of file diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx new file mode 100644 index 0000000..a4e95fb --- /dev/null +++ b/frontend/src/components/ui/separator.tsx @@ -0,0 +1,30 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +interface SeparatorProps extends React.HTMLAttributes { + orientation?: "horizontal" | "vertical" + decorative?: boolean +} + +const Separator = React.forwardRef( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( +
+ ) +) +Separator.displayName = "Separator" + +export { Separator } \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..00b08e3 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,59 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx new file mode 100644 index 0000000..e597fd3 --- /dev/null +++ b/frontend/src/pages/Chat.tsx @@ -0,0 +1,235 @@ +import { useState, useRef, useEffect } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { apiClient } from '@/api/client'; +import { useChatStore } from '@/stores/chatStore'; +import { useSettingsStore } from '@/stores/settingsStore'; +import { + Send, + Loader2, + Bot, + User, + FileText, + Plus +} from 'lucide-react'; +import type { Message, Source } from '@/types'; + +export function Chat() { + const [input, setInput] = useState(''); + const messagesEndRef = useRef(null); + + const { + messages, + addMessage, + isLoading, + setLoading, + startNewConversation + } = useChatStore(); + + const { defaultProvider, defaultModel } = useSettingsStore(); + + // Auto-scroll to bottom + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || isLoading) return; + + const userMessage: Message = { + id: Date.now().toString(), + role: 'user', + content: input.trim(), + timestamp: new Date().toISOString(), + }; + + addMessage(userMessage); + setInput(''); + setLoading(true); + + try { + const response = await apiClient.query( + userMessage.content, + defaultProvider, + defaultModel + ); + + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: response.response, + sources: response.sources, + timestamp: new Date().toISOString(), + }; + + addMessage(assistantMessage); + } catch (error) { + console.error('Error sending message:', error); + + const errorMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: 'Sorry, I encountered an error while processing your request. Please try again.', + timestamp: new Date().toISOString(), + }; + + addMessage(errorMessage); + } finally { + setLoading(false); + } + }; + + const handleNewChat = () => { + if (messages.length > 0 && confirm('Start a new conversation? Current messages will be cleared.')) { + startNewConversation(); + } + }; + + return ( +
+ {/* Header */} +
+
+

Chat

+

+ Ask questions about your documents +

+
+ +
+ + {/* Chat Container */} + + {/* Messages */} + + {messages.length === 0 ? ( +
+ +

Start a conversation

+

Ask questions about your uploaded documents

+
+ {[ + 'What are the main topics in my documents?', + 'Summarize the key points', + 'Find information about...', + 'Explain the concept of...', + ].map((suggestion) => ( + + ))} +
+
+ ) : ( + <> + {messages.map((message) => ( + + ))} + {isLoading && ( +
+ + Thinking... +
+ )} +
+ + )} + + + {/* Input */} +
+
+ setInput(e.target.value)} + disabled={isLoading} + className="flex-1" + /> + +
+

+ Using {defaultProvider} • {defaultModel} +

+
+ +
+ ); +} + +// Chat Message Component +function ChatMessage({ message }: { message: Message }) { + const isUser = message.role === 'user'; + + return ( +
+
+ {/* Avatar */} +
+ {isUser ? ( + + ) : ( + + )} +
+ + {/* Message Content */} +
+
+

{message.content}

+
+ + {/* Sources */} + {!isUser && message.sources && message.sources.length > 0 && ( +
+

Sources:

+
+ {message.sources.map((source, index) => ( + + ))} +
+
+ )} + + {/* Timestamp */} +

+ {new Date(message.timestamp).toLocaleTimeString()} +

+
+
+
+ ); +} + +// Source Badge Component +function SourceBadge({ source }: { source: Source }) { + return ( +
+ + {source.document_name} + ({(source.score * 100).toFixed(0)}%) +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..35938f1 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,196 @@ +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { apiClient } from '@/api/client'; +import type { Document, SystemConfig } from '@/types'; +import { + FileText, + Brain, + TrendingUp, + Upload, + MessageCircle, + ChevronRight, + Loader2 +} from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +export function Dashboard() { + const [documents, setDocuments] = useState([]); + const [config, setConfig] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const navigate = useNavigate(); + + useEffect(() => { + const fetchData = async () => { + try { + const [docs, cfg] = await Promise.all([ + apiClient.getDocuments(), + apiClient.getConfig(), + ]); + setDocuments(docs.slice(0, 5)); // Last 5 documents + setConfig(cfg); + } catch (error) { + console.error('Error fetching dashboard data:', error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+

Dashboard

+

+ Overview of your AgenticRAG system +

+
+ + {/* Stats Cards */} +
+ + + Total Documents + + + +
{documents.length}
+

+ Documents in your knowledge base +

+
+
+ + + + Active Provider + + + +
+ {config?.default_llm_provider || 'N/A'} +
+

+ {config?.default_llm_model || ''} +

+
+
+ + + + System Status + + + +
Active
+

+ All systems operational +

+
+
+
+ + {/* Quick Actions */} +
+ navigate('/documents')}> + +
+ Upload Documents + +
+ + Add new documents to your knowledge base + +
+ + + +
+ + navigate('/chat')}> + +
+ Start Chat + +
+ + Ask questions about your documents + +
+ + + +
+
+ + {/* Recent Documents */} + + +
+
+ Recent Documents + Recently uploaded documents +
+ +
+
+ + {documents.length === 0 ? ( +
+ +

No documents yet

+

Upload your first document to get started

+
+ ) : ( +
+ {documents.map((doc) => ( +
+
+ +
+

{doc.filename}

+

+ {new Date(doc.created_at).toLocaleDateString()} • {(doc.size / 1024).toFixed(1)} KB +

+
+
+
+ {doc.status} +
+
+ ))} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Documents.tsx b/frontend/src/pages/Documents.tsx new file mode 100644 index 0000000..177b961 --- /dev/null +++ b/frontend/src/pages/Documents.tsx @@ -0,0 +1,250 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { apiClient } from '@/api/client'; +import type { Document } from '@/types'; +import { + FileText, + Upload, + Trash2, + Search, + Loader2, + X, + CheckCircle, + AlertCircle +} from 'lucide-react'; + +export function Documents() { + const [documents, setDocuments] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isUploading, setIsUploading] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [dragActive, setDragActive] = useState(false); + + const fetchDocuments = useCallback(async () => { + try { + const docs = await apiClient.getDocuments(); + setDocuments(docs); + } catch (error) { + console.error('Error fetching documents:', error); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchDocuments(); + }, [fetchDocuments]); + + const handleUpload = async (file: File) => { + setIsUploading(true); + try { + await apiClient.uploadDocument(file); + await fetchDocuments(); + } catch (error) { + console.error('Error uploading document:', error); + alert('Failed to upload document. Please try again.'); + } finally { + setIsUploading(false); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('Are you sure you want to delete this document?')) return; + + try { + await apiClient.deleteDocument(id); + await fetchDocuments(); + } catch (error) { + console.error('Error deleting document:', error); + alert('Failed to delete document. Please try again.'); + } + }; + + const handleDrag = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === 'dragenter' || e.type === 'dragover') { + setDragActive(true); + } else if (e.type === 'dragleave') { + setDragActive(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + handleUpload(e.dataTransfer.files[0]); + } + }; + + const handleFileInput = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + handleUpload(e.target.files[0]); + } + }; + + const filteredDocuments = documents.filter((doc) => + doc.filename.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const getStatusIcon = (status: string) => { + switch (status) { + case 'ready': + return ; + case 'processing': + return ; + case 'error': + return ; + default: + return null; + } + }; + + return ( +
+ {/* Header */} +
+

Documents

+

+ Manage your knowledge base documents +

+
+ + {/* Upload Zone */} + + + +

+ {isUploading ? 'Uploading...' : 'Drag & drop your documents here'} +

+

+ Supports PDF, DOCX, TXT, and MD files +

+ + +
+
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> + {searchQuery && ( + + )} +
+ + {/* Documents List */} + + + Your Documents ({filteredDocuments.length}) + + {documents.length === 0 + ? 'No documents uploaded yet' + : 'Click the trash icon to delete a document'} + + + + {isLoading ? ( +
+ +
+ ) : filteredDocuments.length === 0 ? ( +
+ + {searchQuery ? ( +

No documents match your search

+ ) : ( + <> +

No documents yet

+

Upload documents to get started

+ + )} +
+ ) : ( +
+ {filteredDocuments.map((doc) => ( +
+
+ +
+

{doc.filename}

+

+ {new Date(doc.created_at).toLocaleDateString()} • {(doc.size / 1024).toFixed(1)} KB +

+
+
+
+
+ {getStatusIcon(doc.status)} + {doc.status} +
+ +
+
+ ))} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..9974794 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { useAuthStore } from '@/stores/authStore'; +import { apiClient } from '@/api/client'; +import { Brain, Key, Loader2 } from 'lucide-react'; + +export function Login() { + const [apiKey, setApiKey] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + const loginWithApiKey = useAuthStore((state) => state.loginWithApiKey); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + try { + // Test the API key by making a health check + loginWithApiKey(apiKey); + await apiClient.healthCheck(); + navigate('/'); + } catch (err) { + setError('Invalid API key or connection error. Please try again.'); + console.error('Login error:', err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + +
+
+ +
+
+ Welcome to AgenticRAG + + Enter your API key to access the system + +
+
+ +
+ +
+ + setApiKey(e.target.value)} + className="pl-10" + required + /> +
+
+ {error && ( +
+ {error} +
+ )} +
+ + + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..19b6dbf --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,296 @@ +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { apiClient } from '@/api/client'; +import { useSettingsStore } from '@/stores/settingsStore'; +import { useAuthStore } from '@/stores/authStore'; +import type { Provider, Model, SystemConfig } from '@/types'; +import { + Loader2, + CheckCircle, + XCircle, + Sun, + Moon, + Monitor, + LogOut, + Brain, + Key +} from 'lucide-react'; + +export function Settings() { + const [providers, setProviders] = useState([]); + const [models, setModels] = useState([]); + const [config, setConfig] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const { + theme, + setTheme, + defaultProvider, + defaultModel, + setDefaultProvider, + setDefaultModel + } = useSettingsStore(); + + const { logout, apiKey } = useAuthStore(); + + useEffect(() => { + const fetchData = async () => { + try { + const [providersData, configData] = await Promise.all([ + apiClient.getProviders(), + apiClient.getConfig(), + ]); + setProviders(providersData); + setConfig(configData); + + // Fetch models for current provider + if (defaultProvider) { + const modelsData = await apiClient.getProviderModels(defaultProvider); + setModels(modelsData.models); + } + } catch (error) { + console.error('Error fetching settings:', error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [defaultProvider]); + + const handleProviderChange = async (providerId: string) => { + setDefaultProvider(providerId); + setDefaultModel(''); // Reset model when provider changes + + try { + const modelsData = await apiClient.getProviderModels(providerId); + setModels(modelsData.models); + if (modelsData.models.length > 0) { + setDefaultModel(modelsData.models[0].id); + } + } catch (error) { + console.error('Error fetching models:', error); + } + }; + + const handleSave = async () => { + setIsSaving(true); + try { + await apiClient.updateDefaultProvider(defaultProvider, defaultModel); + alert('Settings saved successfully!'); + } catch (error) { + console.error('Error saving settings:', error); + alert('Failed to save settings. Please try again.'); + } finally { + setIsSaving(false); + } + }; + + const handleLogout = () => { + logout(); + window.location.href = '/login'; + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + // Filter configured providers for display + providers.filter((p) => p.configured); + + return ( +
+ {/* Header */} +
+

Settings

+

+ Configure your AgenticRAG preferences +

+
+ + {/* Appearance */} + + + + + Appearance + + + Customize the look and feel of the application + + + +
+ +
+ + + +
+
+
+
+ + {/* LLM Provider */} + + + + + LLM Provider + + + Select your preferred language model provider + + + + {/* Provider Selection */} +
+ +
+ {providers.map((provider) => ( + + ))} +
+
+ + {/* Model Selection */} + {models.length > 0 && ( +
+ +
+ {models.map((model) => ( + + ))} +
+
+ )} + + +
+
+ + {/* System Info */} + {config && ( + + + System Information + + Current system configuration + + + +
+ Embedding Provider + {config.embedding_provider} +
+
+ Embedding Model + {config.embedding_model} +
+
+ Vector Store + Qdrant ({config.qdrant_host}:{config.qdrant_port}) +
+
+ Configured Providers + {config.configured_providers.length} +
+
+
+ )} + + + + {/* Account */} + + + + + Account + + + Manage your account settings + + + +
+
+

API Key

+

+ {apiKey ? `${apiKey.substring(0, 8)}...` : 'Not set'} +

+
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..d3839c2 --- /dev/null +++ b/frontend/src/stores/authStore.ts @@ -0,0 +1,68 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { User } from '@/types'; + +interface AuthState { + // State + user: User | null; + apiKey: string | null; + token: string | null; + isAuthenticated: boolean; + + // Actions + loginWithApiKey: (apiKey: string) => void; + loginWithToken: (token: string, user: User) => void; + logout: () => void; + setUser: (user: User) => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + user: null, + apiKey: null, + token: null, + isAuthenticated: false, + + loginWithApiKey: (apiKey: string) => { + set({ + apiKey, + token: null, + isAuthenticated: true, + user: { id: 'admin', auth_method: 'api_key' }, + }); + }, + + loginWithToken: (token: string, user: User) => { + set({ + token, + apiKey: null, + isAuthenticated: true, + user, + }); + }, + + logout: () => { + set({ + user: null, + apiKey: null, + token: null, + isAuthenticated: false, + }); + }, + + setUser: (user: User) => { + set({ user }); + }, + }), + { + name: 'auth-storage', + partialize: (state) => ({ + apiKey: state.apiKey, + token: state.token, + isAuthenticated: state.isAuthenticated, + user: state.user + }), + } + ) +); \ No newline at end of file diff --git a/frontend/src/stores/chatStore.ts b/frontend/src/stores/chatStore.ts new file mode 100644 index 0000000..ba208ba --- /dev/null +++ b/frontend/src/stores/chatStore.ts @@ -0,0 +1,72 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { Message } from '@/types'; + +interface ChatState { + // State + messages: Message[]; + currentConversationId: string | null; + isLoading: boolean; + conversations: { id: string; title: string; updated_at: string }[]; + + // Actions + addMessage: (message: Message) => void; + setMessages: (messages: Message[]) => void; + clearMessages: () => void; + setLoading: (loading: boolean) => void; + setCurrentConversation: (id: string | null) => void; + startNewConversation: () => void; + addConversation: (conversation: { id: string; title: string; updated_at: string }) => void; +} + +export const useChatStore = create()( + persist( + (set) => ({ + messages: [], + currentConversationId: null, + isLoading: false, + conversations: [], + + addMessage: (message) => { + set((state) => ({ + messages: [...state.messages, message], + })); + }, + + setMessages: (messages) => { + set({ messages }); + }, + + clearMessages: () => { + set({ messages: [], currentConversationId: null }); + }, + + setLoading: (loading) => { + set({ isLoading: loading }); + }, + + setCurrentConversation: (id) => { + set({ currentConversationId: id }); + }, + + startNewConversation: () => { + set({ + messages: [], + currentConversationId: null + }); + }, + + addConversation: (conversation) => { + set((state) => ({ + conversations: [conversation, ...state.conversations].slice(0, 50), // Keep last 50 + })); + }, + }), + { + name: 'chat-storage', + partialize: (state) => ({ + conversations: state.conversations + }), + } + ) +); \ No newline at end of file diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts new file mode 100644 index 0000000..15d30b7 --- /dev/null +++ b/frontend/src/stores/index.ts @@ -0,0 +1,3 @@ +export { useAuthStore } from './authStore'; +export { useChatStore } from './chatStore'; +export { useSettingsStore } from './settingsStore'; \ No newline at end of file diff --git a/frontend/src/stores/settingsStore.ts b/frontend/src/stores/settingsStore.ts new file mode 100644 index 0000000..a49f863 --- /dev/null +++ b/frontend/src/stores/settingsStore.ts @@ -0,0 +1,61 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface SettingsState { + // State + theme: 'light' | 'dark' | 'system'; + defaultProvider: string; + defaultModel: string; + sidebarOpen: boolean; + + // Actions + setTheme: (theme: 'light' | 'dark' | 'system') => void; + setDefaultProvider: (provider: string) => void; + setDefaultModel: (model: string) => void; + toggleSidebar: () => void; + setSidebarOpen: (open: boolean) => void; +} + +export const useSettingsStore = create()( + persist( + (set) => ({ + theme: 'system', + defaultProvider: 'openai', + defaultModel: 'gpt-4o-mini', + sidebarOpen: true, + + setTheme: (theme) => { + set({ theme }); + // Apply theme to document + const root = window.document.documentElement; + root.classList.remove('light', 'dark'); + + if (theme === 'system') { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + root.classList.add(systemTheme); + } else { + root.classList.add(theme); + } + }, + + setDefaultProvider: (provider) => { + set({ defaultProvider: provider }); + }, + + setDefaultModel: (model) => { + set({ defaultModel: model }); + }, + + toggleSidebar: () => { + set((state) => ({ sidebarOpen: !state.sidebarOpen })); + }, + + setSidebarOpen: (open) => { + set({ sidebarOpen: open }); + }, + }), + { + name: 'settings-storage', + } + ) +); \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..7319ab2 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,75 @@ +// Authentication types +export interface User { + id: string; + email?: string; + auth_method: 'api_key' | 'jwt'; +} + +export interface LoginCredentials { + apiKey?: string; + email?: string; + password?: string; +} + +// Document types +export interface Document { + id: string; + filename: string; + content_type: string; + size: number; + created_at: string; + status: 'processing' | 'ready' | 'error'; +} + +export interface DocumentUploadResponse { + document_id: string; + filename: string; + status: string; +} + +// Chat types +export interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; + sources?: Source[]; + timestamp: string; +} + +export interface Source { + document_id: string; + document_name: string; + content: string; + score: number; +} + +export interface ChatResponse { + response: string; + sources: Source[]; +} + +// Provider types +export interface Provider { + id: string; + name: string; + available: boolean; + configured: boolean; + default_model: string; + install_command?: string; +} + +export interface Model { + id: string; + name: string; +} + +// Config types +export interface SystemConfig { + default_llm_provider: string; + default_llm_model: string; + embedding_provider: string; + embedding_model: string; + configured_providers: string[]; + qdrant_host: string; + qdrant_port: number; +} \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..6695da6 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,76 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: [ + "./pages/**/*.{ts,tsx}", + "./components/**/*.{ts,tsx}", + "./app/**/*.{ts,tsx}", + "./src/**/*.{ts,tsx}", + ], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +} \ No newline at end of file diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..d932263 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + "ignoreDeprecations": "6.0", + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Path aliases */ + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..77532d5 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +}) \ No newline at end of file