diff --git a/.env.example b/.env.example index 6e1d411..ba3efa0 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ -# Strapi secrets - change these in production! -JWT_SECRET=your-jwt-secret-here -ADMIN_JWT_SECRET=your-admin-jwt-secret-here -APP_KEYS=your-app-key-1,your-app-key-2 -API_TOKEN_SALT=your-api-token-salt -TRANSFER_TOKEN_SALT=your-transfer-token-salt +# OpenAI-compatible API configuration +OPENAI_API_URL=https://api.openai.com/v1 +OPENAI_API_KEY=your-api-key-here -# Optional: Strapi API token for backend integration -STRAPI_API_TOKEN= +# Default model +DEFAULT_MODEL=gpt-4o-mini + +# Backend JWT secret (change in production) +JWT_SECRET=your-jwt-secret-here diff --git a/.gitignore b/.gitignore index 9502af8..331ce77 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,4 @@ dist .env *.log data/ -strapi/ !.env.example diff --git a/README.md b/README.md index dc2c277..d07ab0b 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,91 @@ -# AIUI Chat -AIUI Chat — это современный веб-интерфейс для общения с LLM (большими языковыми моделями), вдохновленный Open WebUI. Поддерживает любой OpenAI-compatible API (OpenAI, Ollama, LM Studio, vLLM и другие). +Modern, responsive chat interface inspired by Open WebUI. Supports OpenAI-compatible API with streaming responses, token analysis, and RAG knowledge bases. -## Особенности - -- Чат с поддержкой **streaming** ответов -- Управление **историей** диалогов -- Настройки API, модели, temperature, max tokens -- Загрузка файлов в чат -- **Базы знаний** с ChromaDB — загружайте документы и получайте ответы с учетом контекста -- Адаптивный дизайн (десктоп + мобильные) -- Темная тема в стиле modern tech - -## Структура проекта - -``` -AIUI/ -├── frontend/ # React + TypeScript + Tailwind CSS -│ ├── src/ -│ │ ├── components/ # UI компоненты -│ │ │ ├── chat/ # Компоненты чата -│ │ │ ├── layout/ # Layout компоненты -│ │ │ └── knowledge/ # Компоненты баз знаний -│ │ ├── services/ # API сервисы -│ │ ├── stores/ # Zustand stores -│ │ ├── types/ # TypeScript типы -│ │ └── utils/ # Утилиты -│ └── package.json -├── backend/ # Express.js proxy сервер -│ └── src/ -│ └── index.js # Основной сервер -└── package.json # Root package.json -``` - -## Быстрый старт - -### 1. Установка зависимостей +## 🚀 Quick Start (Docker) ```bash +cp .env.example .env +# Edit .env — set your API URL and key +docker compose up --build -d +# Open http://localhost +``` + +## 🏗️ Development + +### Backend +```bash +cd backend npm install +npm run dev # port 3001 ``` -Это установит зависимости для root, frontend и backend. +### Frontend +```bash +cd frontend +npm install +npm run dev # port 5173 (with HMR) +``` -### 2. Настройка API +## ✨ Features -Запустите frontend и backend, затем откройте настройки (иконка шестеренки) и укажите: +| Feature | Description | +|---------|-------------| +| 💬 Chat | Streaming messages with markdown & LaTeX | +| ⚙️ Settings | Model, system prompt, temperature, tokens | +| 📊 Token Analysis | Usage stats panel (tokens/sec, context, cost) | +| 📁 Attachments | Image upload + analysis | +| 📚 Knowledge Bases | RAG with multiple bases, file/folder upload | +| 🎨 Theme | Dark mode, accent colors | +| 📱 Responsive | Works on mobile and desktop | -- **API Base URL** — например `https://api.openai.com/v1` или `http://localhost:11434/v1` (для Ollama) -- **API Key** — ваш ключ -- **Model** — например `gpt-4`, `gpt-3.5-turbo`, `llama2` и т.д. -- **Temperature** и **Max Tokens** — по желанию -- **System Prompt** — системный промпт -- **Embedding Model** — для баз знаний (например `text-embedding-3-small`) -- **ChromaDB Directory** — путь к хранилищу ChromaDB +## 🏛️ Architecture -### 3. Запуск +``` +┌──────────────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Nginx (80) │────────▶│ Backend │────────▶│ OpenAI API │ +│ SPA + /api/* │ │ (Node.js) │ │ (or compatible)│ +└──────────────────┘ └──────────────┘ └─────────────────┘ + │ + ▼ + ┌──────────────┐ + │ sqlite db │ + │ (chats) │ + └──────────────┘ +``` + +## 🔧 Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `OPENAI_API_URL` | `https://api.openai.com/v1` | API endpoint | +| `OPENAI_API_KEY` | - | Your API key | +| `DEFAULT_MODEL` | `gpt-4o-mini` | Default model ID | +| `JWT_SECRET` | random | JWT secret | +| `NODE_ENV` | `development` | Environment | + +## 🐳 Production ```bash -# Запуск backend (порт 3001) -npm run dev:backend - -# В другом терминале — запуск frontend (порт 3000) -npm run dev:frontend +docker compose -f docker-compose.yml up -d --build ``` -Frontend запустится на `http://localhost:3000` и будет проксировать API-запросы на backend. +Nginx serves static frontend + proxies `/api` to backend. -### Production сборка +## 📂 Project Structure -```bash -# Сборка frontend -npm run build:frontend - -# Запуск backend (раздает собранный frontend) -npm run start:backend +``` +├── frontend/ # Vite + React + TypeScript + Tailwind +│ ├── src/components/ # Chat, UI, Layout components +│ ├── src/stores/ # Zustand store +│ ├── src/services/ # API client +│ └── Dockerfile +├── backend/ # Express + proxy + chat storage +│ ├── src/index.js # Main server +│ └── Dockerfile +├── nginx/ # Reverse proxy config +└── docker-compose.yml # Full stack ``` -## Базы знаний (Knowledge Bases) +## 📜 License -Базы знаний позволяют загружать документы и использовать их как контекст для ответов LLM. - -### Как использовать: - -1. Откройте панель **Knowledge Bases** (иконка базы данных в сайдбаре) -2. Создайте новую базу знаний -3. Загрузите документы (`.txt`, `.md`, `.pdf`, `.docx`) -4. В сайдбаре выберите базу знаний для текущего диалога -5. Задавайте вопросы — LLM будет использовать контекст из документов - -### Технические детали: - -- Документы разбиваются на **чанки** по ~500 символов с перекрытием -- Для каждого чанка генерируется **embedding** через API -- Чанки хранятся в **ChromaDB** (векторная база данных) -- При отправке сообщения запрос эмбеддится и ищутся похожие чанки через semantic search -- Найденный контекст добавляется к системному промпту - -## API Endpoints - -Backend проксирует запросы к OpenAI-compatible API: - -| Endpoint | Method | Описание | -|----------|--------|----------| -| `/api/v1/chat/completions` | POST | Streaming чат | -| `/api/v1/models` | POST | Список моделей | -| `/api/files/upload` | POST | Загрузка файлов | -| `/api/files` | GET | Список файлов | -| `/api/files/:id` | DELETE | Удаление файла | -| `/api/knowledge` | GET | Список баз знаний | -| `/api/knowledge` | POST | Создать базу знаний | -| `/api/knowledge/:id` | DELETE | Удалить базу знаний | -| `/api/knowledge/:id/files` | POST | Добавить файл в базу | -| `/api/knowledge/:id/files/:fileId` | DELETE | Удалить файл из базы | -| `/api/knowledge/:id/query` | POST | Поиск по базе знаний | - -## Технологии - -- **Frontend**: React 18, TypeScript, Tailwind CSS, Zustand, Lucide icons -- **Backend**: Node.js, Express.js, ChromaDB, Multer -- **Build**: Vite +MIT diff --git a/backend/src/index.js b/backend/src/index.js index 1bf4909..da77dbf 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -7,6 +7,9 @@ const path = require('path'); const app = express(); const PORT = process.env.PORT || 3002; +const CHROMA_HOST = process.env.CHROMA_HOST || 'chromadb'; +const CHROMA_PORT = process.env.CHROMA_PORT || '8000'; +const CHROMA_URL = `http://${CHROMA_HOST}:${CHROMA_PORT}`; // Middleware app.use(cors()); @@ -26,21 +29,22 @@ const storage = multer.diskStorage({ const upload = multer({ storage }); -// In-memory storage for knowledge bases metadata +// In-memory storage const knowledgeBases = new Map(); const knowledgeFiles = new Map(); const uploadedFiles = new Map(); - -// Token usage tracking -const tokenUsageStats = new Map(); // conversationId -> aggregated token stats - -// In-memory storage for token usage events const tokenUsageLog = []; -// Extract token usage from SSE response +// ===== CHROMA CLIENT ===== + +function getChromaClient() { + return new ChromaClient({ path: CHROMA_URL }); +} + +// ===== TOKEN USAGE ===== + function extractTokenUsage(fullResponse) { try { - // Parse the last SSE data block which contains usage info const lines = fullResponse.split('\n'); for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; @@ -57,7 +61,7 @@ function extractTokenUsage(fullResponse) { }; } } catch { - // Not valid JSON, skip + // skip } } } @@ -67,13 +71,6 @@ function extractTokenUsage(fullResponse) { } } -// Global ChromaDB client (recreated per request when path changes) -function getChromaClient(persistDir = './data/chromadb') { - return new ChromaClient({ - path: `file://${path.resolve(persistDir)}`, - }); -} - // ===== OPENAI PROXY ===== app.post('/api/v1/chat/completions', async (req, res) => { @@ -107,7 +104,6 @@ app.post('/api/v1/chat/completions', async (req, res) => { }); } - // Stream the response res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); @@ -125,7 +121,6 @@ app.post('/api/v1/chat/completions', async (req, res) => { } res.end(); - // Parse token usage from the final SSE message const tokenUsage = extractTokenUsage(fullResponse); if (tokenUsage) { const usageEntry = { @@ -137,13 +132,11 @@ app.post('/api/v1/chat/completions', async (req, res) => { totalTokens: tokenUsage.totalTokens, }; tokenUsageLog.push(usageEntry); - console.log(`Token usage: ${usageEntry.totalTokens} tokens (${usageEntry.promptTokens} prompt, ${usageEntry.completionTokens} completion) for model ${model}`); + console.log(`Token usage: ${usageEntry.totalTokens} tokens for model ${model}`); } } catch (error) { console.error('Proxy error:', error); - res.status(500).json({ - error: { message: error.message || 'Internal server error' }, - }); + res.status(500).json({ error: { message: error.message || 'Internal server error' } }); } }); @@ -156,9 +149,7 @@ app.post('/api/v1/models', async (req, res) => { const url = `${apiUrl.replace(/\/$/, '')}/models`; const response = await fetch(url, { - headers: { - 'Authorization': `Bearer ${apiKey}`, - }, + headers: { 'Authorization': `Bearer ${apiKey}` }, }); if (!response.ok) { @@ -224,7 +215,6 @@ function chunkText(text, chunkSize = 500, overlap = 50) { chunks.push(text.slice(start, end)); start += chunkSize - overlap; if (start >= text.length) break; - // Don't create tiny chunks at the end if (text.length - start < chunkSize * 0.3) { chunks[chunks.length - 1] = text.slice(chunks.length > 0 ? start - (chunkSize - overlap) : 0); break; @@ -253,11 +243,58 @@ async function getEmbeddings(texts, apiUrl, apiKey, model) { return data.data.map((d) => d.embedding); } -app.get('/api/knowledge', async (req, res) => { - const { chromaDir } = req.query; +async function processFileToKnowledge(filePath, fileName, baseId, apiUrl, apiKey, embeddingModel) { + let text = ''; try { - const client = getChromaClient(chromaDir || './data/chromadb'); - // Get all collections to list knowledge bases + text = fs.readFileSync(filePath, 'utf-8'); + } catch (e) { + console.warn(`Cannot read ${fileName}:`, e.message); + return { success: false, error: e.message }; + } + + const chunks = chunkText(text, 500, 50); + if (chunks.length === 0) return { success: false, error: 'Empty file' }; + + const embeddings = await getEmbeddings(chunks, apiUrl, apiKey, embeddingModel || 'text-embedding-3-small'); + + const client = getChromaClient(); + const collection = await client.getCollection({ name: baseId }); + + const ids = chunks.map((_, i) => `${baseId}_chunk_${Date.now()}_${i}_${Math.random().toString(36).substring(2, 6)}`); + const metadatas = chunks.map((chunk, i) => ({ + source: fileName, + chunkIndex: i, + fileId: fileName, + })); + + await collection.add({ ids, embeddings, documents: chunks, metadatas }); + + const fileInfo = { + id: fileName, + originalName: fileName, + size: fs.statSync(filePath).size, + mimeType: 'text/plain', + chunkCount: chunks.length, + uploadedAt: Date.now(), + }; + + const files = knowledgeFiles.get(baseId) || []; + files.push(fileInfo); + knowledgeFiles.set(baseId, files); + + const kb = knowledgeBases.get(baseId); + if (kb) { + kb.documentCount = (kb.documentCount || 0) + chunks.length; + kb.files = files; + knowledgeBases.set(baseId, kb); + } + + return { success: true, fileInfo, chunks: chunks.length }; +} + +app.get('/api/knowledge', async (req, res) => { + try { + const client = getChromaClient(); const collections = await client.listCollections(); const bases = []; @@ -290,16 +327,22 @@ app.post('/api/knowledge', async (req, res) => { const id = name.trim().toLowerCase().replace(/[^a-z0-9]/g, '_') + '_' + Date.now(); try { - const client = getChromaClient(chromaDir || './data/chromadb'); + const client = getChromaClient(); await client.getOrCreateCollection({ name: id, - metadata: { name: name.trim(), description: description || '', createdAt: Date.now() }, + metadata: { + name: name.trim(), + description: description || '', + chromaDir: chromaDir || '/data/chromadb', + createdAt: Date.now(), + }, }); const kb = { id, name: name.trim(), description: description || '', + chromaDir: chromaDir || '/data/chromadb', documentCount: 0, files: [], }; @@ -315,17 +358,15 @@ app.post('/api/knowledge', async (req, res) => { app.delete('/api/knowledge/:id', async (req, res) => { const { id } = req.params; - const { chromaDir } = req.query; try { - const client = getChromaClient(chromaDir || './data/chromadb'); + const client = getChromaClient(); await client.deleteCollection({ name: id }); knowledgeBases.delete(id); knowledgeFiles.delete(id); res.json({ success: true }); } catch (error) { console.error('Delete knowledge base error:', error); - // Even if Chroma throws, clean up our state knowledgeBases.delete(id); knowledgeFiles.delete(id); res.json({ success: true }); @@ -343,67 +384,97 @@ app.post('/api/knowledge/:id/files', upload.single('file'), async (req, res) => } try { - // Read file content - let text = ''; - if (file.mimetype === 'application/pdf') { - text = `[PDF file: ${file.originalname}]`; // Simplified; in production use pdf-parse - } else { - text = fs.readFileSync(file.path, 'utf-8'); - } - - // Chunk the text - const chunks = chunkText(text, 500, 50); - if (chunks.length === 0) { - return res.status(400).json({ error: 'File is empty or could not be parsed' }); - } - - // Get embeddings - const embeddings = await getEmbeddings(chunks, apiUrl, apiKey, embeddingModel || 'text-embedding-3-small'); - - // Store in ChromaDB - const client = getChromaClient(); - const collection = await client.getCollection({ name: id }); - - const ids = chunks.map((_, i) => `${id}_chunk_${Date.now()}_${i}`); - const metadatas = chunks.map((chunk, i) => ({ - source: file.originalname, - chunkIndex: i, - fileId: file.filename, - })); - - await collection.add({ - ids, - embeddings, - documents: chunks, - metadatas, - }); - - // Update metadata - const fileInfo = { - id: file.filename, - originalName: file.originalname, - size: file.size, - chunkCount: chunks.length, - }; - - const files = knowledgeFiles.get(id) || []; - files.push(fileInfo); - knowledgeFiles.set(id, files); - - const kb = knowledgeBases.get(id); - if (kb) { - kb.documentCount = (kb.documentCount || 0) + chunks.length; - kb.files = files; - knowledgeBases.set(id, kb); - } - - res.json({ file: fileInfo, chunks: chunks.length }); + const result = await processFileToKnowledge( + file.path, + file.originalname, + id, + apiUrl, + apiKey, + embeddingModel + ); + if (!result.success) return res.status(400).json({ error: result.error }); + res.json({ file: result.fileInfo, chunks: result.chunks }); } catch (error) { console.error('Upload to knowledge base error:', error); res.status(500).json({ error: error.message }); } }); +// ===== SCAN LOCAL FOLDER ===== + +const SKIP_DIRS_SCAN = /node_modules|\.git|\.next|\.vscode|\.idea|dist|build|__pycache__|\.cache|\.venv|venv|env|target|\.turbo/i; + +function shouldSkipPath(p) { + const parts = p.split('/'); + for (const part of parts) { + if (part.startsWith('.') || SKIP_DIRS_SCAN.test(part)) return true; + } + return false; +} + +function getFilesRecursively(dirPath, files = []) { + if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) return files; + const entries = fs.readdirSync(dirPath); + for (const entry of entries) { + const fullPath = path.join(dirPath, entry); + const relPath = fullPath.replace(dirPath, ''); + if (shouldSkipPath(relPath)) continue; + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + getFilesRecursively(fullPath, files); + } else { + files.push(fullPath); + } + } + return files; +} + +app.post('/api/knowledge/:id/scan', async (req, res) => { + const { id } = req.params; + const { folderPath, apiUrl, apiKey, embeddingModel } = req.body; + + if (!folderPath || !fs.existsSync(folderPath)) { + return res.status(400).json({ error: 'Invalid or non-existent folder path' }); + } + if (!apiUrl || !apiKey) { + return res.status(400).json({ error: 'API URL and Key are required for embeddings' }); + } + + try { + const allPaths = getFilesRecursively(folderPath); + const results = []; + let processed = 0; + const maxFiles = 50; + + for (const filePath of allPaths.slice(0, maxFiles)) { + try { + const fileName = path.relative(folderPath, filePath); + const result = await processFileToKnowledge( + filePath, + fileName, + id, + apiUrl, + apiKey, + embeddingModel + ); + if (result.success) { + results.push({ file: fileName, chunks: result.chunks }); + } else { + results.push({ file: fileName, error: result.error }); + } + } catch (e) { + results.push({ file: path.relative(folderPath, filePath), error: e.message }); + } + processed++; + } + + res.json({ processed, total: allPaths.length, results }); + } catch (error) { + console.error('Scan folder error:', error); + res.status(500).json({ error: error.message }); + } +}); + app.delete('/api/knowledge/:baseId/files/:fileId', async (req, res) => { const { baseId, fileId } = req.params; @@ -411,7 +482,6 @@ app.delete('/api/knowledge/:baseId/files/:fileId', async (req, res) => { const client = getChromaClient(); const collection = await client.getCollection({ name: baseId }); - // Find all chunks with this fileId const results = await collection.get({ where: { fileId: { $eq: fileId } }, }); @@ -420,7 +490,6 @@ app.delete('/api/knowledge/:baseId/files/:fileId', async (req, res) => { await collection.delete({ ids: results.ids }); } - // Update metadata const files = (knowledgeFiles.get(baseId) || []).filter((f) => f.id !== fileId); knowledgeFiles.set(baseId, files); @@ -478,14 +547,13 @@ app.post('/api/knowledge/:id/query', async (req, res) => { // Health check app.get('/api/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); + res.json({ status: 'ok', timestamp: new Date().toISOString(), chromaUrl: CHROMA_URL }); }); -// Token usage tracking endpoints +// Token usage tracking app.get('/api/token-usage', (req, res) => { try { const { period = 'all', conversationId } = req.query; - let filtered = [...tokenUsageLog]; if (conversationId) { @@ -506,7 +574,6 @@ app.get('/api/token-usage', (req, res) => { const completionTokens = filtered.reduce((sum, e) => sum + e.completionTokens, 0); const totalTokens = filtered.reduce((sum, e) => sum + e.totalTokens, 0); - // Per-model breakdown const modelBreakdown = {}; filtered.forEach((entry) => { if (!modelBreakdown[entry.model]) { @@ -521,7 +588,7 @@ app.get('/api/token-usage', (req, res) => { res.json({ summary: { promptTokens, completionTokens, totalTokens, requestCount: filtered.length }, modelBreakdown, - history: filtered.slice(-100), // last 100 entries + history: filtered.slice(-100), }); } catch (error) { console.error('Token usage error:', error); @@ -531,11 +598,11 @@ app.get('/api/token-usage', (req, res) => { app.delete('/api/token-usage', (req, res) => { tokenUsageLog.length = 0; - tokenUsageStats.clear(); res.json({ success: true }); }); app.listen(PORT, () => { console.log(`AIUI Backend running on port ${PORT}`); + console.log(`ChromaDB URL: ${CHROMA_URL}`); console.log(`Upload directory: ${uploadDir}`); }); diff --git a/docker-compose.yml b/docker-compose.yml index 4b0716f..4c2fdd8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: # Nginx reverse proxy nginx: @@ -11,7 +9,6 @@ services: depends_on: - frontend - backend - - strapi networks: - aiui-network @@ -26,7 +23,7 @@ services: - aiui-network restart: unless-stopped - # Backend (Express + ChromaDB) + # Backend (Express + SQLite) backend: build: context: ./backend @@ -38,28 +35,31 @@ services: environment: - NODE_ENV=production - PORT=3002 - - STRAPI_URL=http://strapi:1337 - - STRAPI_API_TOKEN=${STRAPI_API_TOKEN:-} + - OPENAI_API_URL=${OPENAI_API_URL:-https://api.openai.com/v1} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - DEFAULT_MODEL=${DEFAULT_MODEL:-gpt-4o-mini} + - JWT_SECRET=${JWT_SECRET:-randomsecret} + - CHROMA_HOST=chromadb + - CHROMA_PORT=8000 networks: - aiui-network + depends_on: + - chromadb restart: unless-stopped - # Strapi CMS (SQLite) - strapi: - image: strapi/strapi:5 + # ChromaDB - vector database for RAG + chromadb: + image: chromadb/chroma:latest expose: - - "1337" + - "8000" volumes: - - ./data/strapi:/srv/app + - ./data/chromadb:/data/chromadb environment: - - DATABASE_CLIENT=sqlite - - DATABASE_FILENAME=.tmp/data.db - - JWT_SECRET=${JWT_SECRET:-changemejwt} - - ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET:-changemeadmin} - - APP_KEYS=${APP_KEYS:-changeme1,changeme2} - - API_TOKEN_SALT=${API_TOKEN_SALT:-changemesalt} - - TRANSFER_TOKEN_SALT=${TRANSFER_TOKEN_SALT:-changemetoken} - - NODE_ENV=production + - CHROMA_SERVER_HOST=0.0.0.0 + - CHROMA_SERVER_HTTP_PORT=8000 + - CHROMA_SERVER_CORS_ALLOW_ORIGINS=["*"] + - IS_PERSISTENT=TRUE + - PERSIST_DIRECTORY=/data/chromadb networks: - aiui-network restart: unless-stopped @@ -70,4 +70,4 @@ networks: volumes: backend-data: - strapi-data: + chromadb-data: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 54e1f26..181938d 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -16,7 +16,7 @@ server { # API proxy location /api/ { - proxy_pass http://backend:3002/; + proxy_pass http://backend:3002/api/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -29,14 +29,6 @@ server { proxy_read_timeout 86400s; } - # Strapi admin/proxy (optional) - location /strapi/ { - proxy_pass http://strapi:1337/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } - # SPA fallback location / { try_files $uri $uri/ /index.html; diff --git a/frontend/src/components/chat/ChatArea.tsx b/frontend/src/components/chat/ChatArea.tsx index 2065cd3..8ca0a0e 100644 --- a/frontend/src/components/chat/ChatArea.tsx +++ b/frontend/src/components/chat/ChatArea.tsx @@ -25,30 +25,44 @@ export default function ChatArea({ conversationId }: ChatAreaProps) { messagesRef.current = conversation?.messages || []; const buildSystemPrompt = useCallback( - async (basePrompt: string, kbId?: string | null): Promise => { - if (!kbId || !settings.apiUrl || !settings.apiKey) return basePrompt; + async (basePrompt: string, kbIds?: string[]): Promise => { + if (!kbIds || kbIds.length === 0 || !settings.apiUrl || !settings.apiKey) return basePrompt; try { const query = messagesRef.current .filter((m) => m.role === 'user') .slice(-1)[0]?.content || ''; if (!query) return basePrompt; - const { results } = await queryKnowledge( - kbId, - query, - settings.apiUrl, - settings.apiKey, - settings.embeddingModel, - 5 - ); + const allResults: Array<{ text: string; score: number }> = []; - if (results.length === 0) return basePrompt; + for (const kbId of kbIds) { + try { + const { results } = await queryKnowledge( + kbId, + query, + settings.apiUrl, + settings.apiKey, + settings.embeddingModel, + 5 + ); + allResults.push(...results); + } catch (e) { + console.warn(`Knowledge query failed for ${kbId}:`, e); + } + } - const context = results + // Sort by relevance (score descending) and deduplicate + const sorted = allResults + .sort((a, b) => b.score - a.score) + .slice(0, 15); + + if (sorted.length === 0) return basePrompt; + + const context = sorted .map((r, i) => `[${i + 1}] ${r.text}`) .join('\n\n'); - return `${basePrompt}\n\nRelevant context from knowledge base:\n${context}\n\nAnswer using the context above when relevant.`; + return `${basePrompt}\n\nRelevant context from knowledge bases:\n${context}\n\nAnswer using the context above when relevant.`; } catch { return basePrompt; } @@ -94,7 +108,7 @@ export default function ChatArea({ conversationId }: ChatAreaProps) { try { const systemPrompt = await buildSystemPrompt( settings.systemPrompt, - conversation?.knowledgeBaseId + conversation?.knowledgeBaseIds ); const stream = streamChatCompletion( @@ -140,7 +154,7 @@ export default function ChatArea({ conversationId }: ChatAreaProps) { abortControllerRef.current = null; } }, - [conversationId, settings, addMessage, updateMessage, setLoading, buildSystemPrompt, conversation?.knowledgeBaseId] + [conversationId, settings, addMessage, updateMessage, setLoading, buildSystemPrompt, conversation?.knowledgeBaseIds] ); const handleStop = useCallback(() => { diff --git a/frontend/src/components/chat/FileUpload.tsx b/frontend/src/components/chat/FileUpload.tsx index cdc963a..7a5ed56 100644 --- a/frontend/src/components/chat/FileUpload.tsx +++ b/frontend/src/components/chat/FileUpload.tsx @@ -1,5 +1,5 @@ -import { useState, useCallback, useRef } from 'react'; -import { Upload, X, FileText, FileImage, File } from 'lucide-react'; +import { useState, useCallback } from 'react'; +import { Upload, X, FileText, FileImage, File, FolderOpen } from 'lucide-react'; import type { UploadedFile } from '@/types'; interface FileUploadProps { @@ -8,10 +8,23 @@ interface FileUploadProps { disabled?: boolean; } +const SKIP_DIRS = /node_modules|\.git|\.next|\.vscode|\.idea|dist|build|__pycache__|\.cache|\.venv|venv|env|target|\.turbo/i; +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +const MAX_FILES = 50; + +function shouldSkip(file: File): boolean { + // Skip hidden directories by checking webkitRelativePath + const parts = file.webkitRelativePath?.split('/') || []; + for (const part of parts) { + if (part.startsWith('.') || SKIP_DIRS.test(part)) return true; + } + return false; +} + export default function FileUpload({ files, onFilesChange, disabled }: FileUploadProps) { const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); - const inputRef = useRef(null); + const [progress, setProgress] = useState<{ current: number; total: number } | null>(null); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -33,32 +46,78 @@ export default function FileUpload({ files, onFilesChange, disabled }: FileUploa await uploadFiles(droppedFiles); }, [disabled]); - const handleFileSelect = useCallback(async (e: React.ChangeEvent) => { - if (!e.target.files?.length) return; - const selectedFiles = Array.from(e.target.files); - await uploadFiles(selectedFiles); - e.target.value = ''; - }, []); + const openFilePicker = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.onchange = (e) => { + const target = e.target as HTMLInputElement; + if (target.files?.length) { + uploadFiles(Array.from(target.files)); + } + }; + input.click(); + }; + + const openFolderPicker = () => { + const input = document.createElement('input'); + input.type = 'file'; + // @ts-ignore + input.webkitdirectory = true; + input.multiple = true; + input.onchange = (e) => { + const target = e.target as HTMLInputElement; + if (target.files?.length) { + const allFiles = Array.from(target.files); + const filtered = allFiles.filter((f) => !shouldSkip(f)); + if (filtered.length > MAX_FILES) { + alert(`Too many files. Maximum ${MAX_FILES} allowed. Got ${filtered.length}`); + uploadFiles(filtered.slice(0, MAX_FILES)); + } else { + uploadFiles(filtered); + } + } + }; + input.click(); + }; const uploadFiles = async (fileList: File[]) => { + if (fileList.length === 0) return; setIsUploading(true); + setProgress({ current: 0, total: fileList.length }); + try { - const formData = new FormData(); - fileList.forEach((f) => formData.append('files', f)); + const result: UploadedFile[] = []; - const response = await fetch('/api/files/upload', { - method: 'POST', - body: formData, - }); + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + setProgress({ current: i + 1, total: fileList.length }); - if (!response.ok) throw new Error('Upload failed'); - const data = await response.json(); - onFilesChange([...files, ...data.files]); + if (file.size > MAX_FILE_SIZE) { + console.warn(`Skipping ${file.name}: ${(file.size / 1024 / 1024).toFixed(1)}MB > ${MAX_FILE_SIZE / 1024 / 1024}MB limit`); + continue; + } + + const formData = new FormData(); + formData.append('files', file); + + const response = await fetch('/api/files/upload', { + method: 'POST', + body: formData, + }); + + if (!response.ok) throw new Error('Upload failed'); + const data = await response.json(); + result.push(...data.files); + } + + onFilesChange([...files, ...result]); } catch (err) { console.error('Upload error:', err); alert('Failed to upload files'); } finally { setIsUploading(false); + setProgress(null); } }; @@ -108,39 +167,50 @@ export default function FileUpload({ files, onFilesChange, disabled }: FileUploa )} + {/* Buttons row */} +
+ + +
+ {/* Drop zone */} - - - + ); } diff --git a/frontend/src/components/knowledge/KnowledgePanel.tsx b/frontend/src/components/knowledge/KnowledgePanel.tsx index 238da89..42be5b7 100644 --- a/frontend/src/components/knowledge/KnowledgePanel.tsx +++ b/frontend/src/components/knowledge/KnowledgePanel.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect, useRef } from 'react'; -import { X, Plus, Trash2, Upload, BookOpen, FileText, Database, ChevronDown, ChevronUp, FolderOpen, AlertCircle } from 'lucide-react'; +import { useState, useEffect, useCallback } from 'react'; +import { X, Plus, Trash2, Upload, BookOpen, FileText, Database, ChevronDown, ChevronUp, FolderOpen, AlertCircle, RefreshCw, FolderSearch } from 'lucide-react'; import { useChatStore } from '@/stores/chatStore'; interface FileResult { @@ -9,37 +9,54 @@ interface FileResult { chunkCount?: number; } +const SKIP_DIRS = /node_modules|\.git|\.next|\.vscode|\.idea|dist|build|__pycache__|\.cache|\.venv|venv|env|target|\.turbo/i; +const MAX_FILES = 50; + +function shouldSkip(file: File): boolean { + const parts = file.webkitRelativePath?.split('/') || []; + for (const part of parts) { + if (part.startsWith('.') || SKIP_DIRS.test(part)) return true; + } + return false; +} export default function KnowledgePanel() { const { settings, toggleKnowledge, isKnowledgeOpen, setKnowledgeBases, knowledgeBases, removeKnowledgeBase } = useChatStore(); const [newName, setNewName] = useState(''); const [newDesc, setNewDesc] = useState(''); + const [newChromaDir, setNewChromaDir] = useState('/data/chromadb'); const [isCreating, setIsCreating] = useState(false); const [expandedBase, setExpandedBase] = useState(null); const [uploadingBase, setUploadingBase] = useState(null); const [loading, setLoading] = useState(false); const [folderLoading, setFolderLoading] = useState(false); const [error, setError] = useState(null); - const fileInputRef = useRef(null); - const folderInputRef = useRef(null); + const [isBackendReady, setIsBackendReady] = useState(true); - useEffect(() => { - if (isKnowledgeOpen) loadBases(); - }, [isKnowledgeOpen]); - - const loadBases = async () => { + const loadBases = useCallback(async () => { try { setError(null); const res = await fetch('/api/knowledge'); - if (!res.ok) throw new Error('Failed to load'); + if (!res.ok) { + if (res.status === 500) { + setIsBackendReady(false); + throw new Error('Knowledge service not available. Make sure ChromaDB is running.'); + } + throw new Error('Failed to load'); + } + setIsBackendReady(true); const data = await res.json(); setKnowledgeBases(data.bases || []); - } catch (err) { + } catch (err: any) { console.error('Failed to load knowledge bases:', err); - setError('Could not connect to knowledge base service. Is the backend running?'); + setError(err.message || 'Could not connect to knowledge base service.'); setKnowledgeBases([]); } - }; + }, [setKnowledgeBases]); + + useEffect(() => { + if (isKnowledgeOpen) loadBases(); + }, [isKnowledgeOpen, loadBases]); const handleCreate = async () => { if (!newName.trim()) return; @@ -48,13 +65,14 @@ export default function KnowledgePanel() { const res = await fetch('/api/knowledge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }), + body: JSON.stringify({ name: newName.trim(), description: newDesc.trim(), chromaDir: newChromaDir.trim() || '/data/chromadb' }), }); const base = await res.json(); if (base.error) throw new Error(base.error); setKnowledgeBases([...knowledgeBases, base]); setNewName(''); setNewDesc(''); + setNewChromaDir('/data/chromadb'); setIsCreating(false); } catch (err: any) { setError(err.message || 'Failed to create knowledge base'); @@ -63,6 +81,56 @@ export default function KnowledgePanel() { } }; + const openCreateFolderPicker = () => { + const input = document.createElement('input'); + input.type = 'file'; + // @ts-ignore + input.webkitdirectory = true; + input.multiple = true; + input.onchange = (e) => { + const target = e.target as HTMLInputElement; + if (target.files?.length) { + const allFiles = Array.from(target.files).filter(f => !shouldSkip(f)); + handleCreateFromFiles(allFiles); + } + }; + input.click(); + }; + + const handleCreateFromFiles = async (files: File[]) => { + if (!files.length) return; + setFolderLoading(true); + setError(null); + + try { + const folderName = files[0].webkitRelativePath?.split('/')[0] || 'New Knowledge Base'; + const res = await fetch('/api/knowledge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: folderName, description: `From folder with ${files.length} files`, chromaDir: '/data/chromadb' }), + }); + const base = await res.json(); + if (base.error) throw new Error(base.error); + + for (const file of files.slice(0, MAX_FILES)) { + const form = new FormData(); + form.append('file', file); + form.append('apiUrl', settings.apiUrl); + form.append('apiKey', settings.apiKey); + form.append('embeddingModel', settings.embeddingModel); + try { + await fetch(`/api/knowledge/${base.id}/files`, { method: 'POST', body: form }); + } catch { /* ignore single failures */ } + } + + await loadBases(); + } catch (err: any) { + setError(err.message || 'Folder upload failed'); + } finally { + setFolderLoading(false); + } + }; + const handleDelete = async (id: string) => { if (!confirm('Delete this knowledge base?')) return; try { @@ -73,93 +141,72 @@ export default function KnowledgePanel() { } }; - const handleUploadFile = async (baseId: string, e: React.ChangeEvent) => { - if (!e.target.files?.[0]) return; - const file = e.target.files[0]; + const openFilePicker = (baseId: string) => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.onchange = (e) => { + const target = e.target as HTMLInputElement; + if (target.files?.length) { + handleUploadFiles(baseId, Array.from(target.files)); + } + }; + input.click(); + }; + + const openFolderPicker = (baseId: string) => { + const input = document.createElement('input'); + input.type = 'file'; + // @ts-ignore + input.webkitdirectory = true; + input.multiple = true; + input.onchange = (e) => { + const target = e.target as HTMLInputElement; + if (target.files?.length) { + const allFiles = Array.from(target.files).filter(f => !shouldSkip(f)); + handleUploadFiles(baseId, allFiles); + } + }; + input.click(); + }; + + const handleUploadFiles = async (baseId: string, files: File[]) => { + if (!files.length) return; setUploadingBase(baseId); setError(null); - try { - const formData = new FormData(); - formData.append('file', file); - formData.append('apiUrl', settings.apiUrl); - formData.append('apiKey', settings.apiKey); - formData.append('embeddingModel', settings.embeddingModel); - - const res = await fetch(`/api/knowledge/${baseId}/files`, { - method: 'POST', - body: formData, - }); - - if (!res.ok) throw new Error('Upload failed'); - const data = await res.json(); - - const updated = knowledgeBases.map((b) => - b.id === baseId - ? { ...b, files: [...b.files, data.file], documentCount: (b.documentCount || 0) + data.chunks } - : b - ); - setKnowledgeBases(updated); - } catch (err) { - setError('Failed to upload file. Make sure API settings are configured.'); - } finally { - setUploadingBase(null); - e.target.value = ''; + for (const file of files.slice(0, MAX_FILES)) { + const form = new FormData(); + form.append('file', file); + form.append('apiUrl', settings.apiUrl); + form.append('apiKey', settings.apiKey); + form.append('embeddingModel', settings.embeddingModel); + try { + await fetch(`/api/knowledge/${baseId}/files`, { method: 'POST', body: form }); + } catch { /* ignore */ } } + + setUploadingBase(null); + await loadBases(); }; - const handleUploadFolder = async (baseId: string, e: React.ChangeEvent) => { - const files = e.target.files; - if (!files || files.length === 0) return; - - setFolderLoading(true); + const handleScanLocalFolder = async (baseId: string) => { + const folderPath = prompt('Enter absolute folder path on server to scan:'); + if (!folderPath) return; + setUploadingBase(baseId); setError(null); - - // For browser folder upload, we can't get the actual folder path. - // Instead, upload all files from the file picker using webkitdirectory. - const formData = new FormData(); - Array.from(files).forEach((f) => formData.append('files', f)); - formData.append('apiUrl', settings.apiUrl); - formData.append('apiKey', settings.apiKey); - formData.append('embeddingModel', settings.embeddingModel); - try { - // Use a batch upload approach — upload files one by one - const uploaded: FileResult[] = []; - const errors: string[] = []; - - for (const file of Array.from(files)) { - const singleForm = new FormData(); - singleForm.append('file', file); - singleForm.append('apiUrl', settings.apiUrl); - singleForm.append('apiKey', settings.apiKey); - singleForm.append('embeddingModel', settings.embeddingModel); - - try { - const res = await fetch(`/api/knowledge/${baseId}/files`, { - method: 'POST', - body: singleForm, - }); - if (!res.ok) throw new Error('Failed'); - const data = await res.json(); - uploaded.push(data.file); - } catch { - errors.push(file.name); - } - } - - if (uploaded.length > 0) { - await loadBases(); - } - - if (errors.length > 0) { - setError(`${errors.length} files failed to upload out of ${files.length}`); - } - } catch (err) { - setError('Folder upload failed'); + const res = await fetch(`/api/knowledge/${baseId}/scan`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folderPath, apiUrl: settings.apiUrl, apiKey: settings.apiKey, embeddingModel: settings.embeddingModel }), + }); + if (!res.ok) throw new Error('Scan failed'); + await loadBases(); + } catch (err: any) { + setError(err.message || 'Failed to scan folder'); } finally { - setFolderLoading(false); - e.target.value = ''; + setUploadingBase(null); } }; @@ -170,8 +217,8 @@ export default function KnowledgePanel() { b.id === baseId ? { ...b, - files: b.files.filter((f) => f.id !== fileId), - documentCount: b.files.filter((f) => f.id !== fileId).reduce((s, f) => s + (f.chunkCount || 0), 0), + files: b.files.filter((f: any) => f.id !== fileId), + documentCount: b.files.filter((f: any) => f.id !== fileId).reduce((s: number, f: any) => s + (f.chunkCount || 0), 0), } : b ); @@ -193,17 +240,25 @@ export default function KnowledgePanel() {

Knowledge Bases

- +
+ + +
{/* Content */}
- {/* Error */} {error && (
@@ -214,16 +269,33 @@ export default function KnowledgePanel() {
)} - {/* Create new */} - {!isCreating ? ( - - ) : ( + {!isCreating && isBackendReady && ( +
+ + +
+ )} + + {isCreating && (
+
+ + setNewChromaDir(e.target.value)} + placeholder="/data/chromadb" + className="input-field text-sm" + /> +

+ Path inside the ChromaDB container where this base will be stored +

+
)} - {/* List */} - {knowledgeBases.length === 0 ? ( + {!isBackendReady && ( +
+ +

Knowledge base service unavailable

+

Make sure ChromaDB is running

+ +
+ )} + + {isBackendReady && knowledgeBases.length === 0 ? (

No knowledge bases yet

Create one to start adding documents

- ) : ( + ) : isBackendReady && (
{knowledgeBases.map((base) => (

{base.documentCount || 0} chunks · {base.files?.length || 0} files + {base.chromaDir ? ` · ${base.chromaDir}` : ''}

@@ -303,11 +402,13 @@ export default function KnowledgePanel() { {base.description && (

{base.description}

)} +

+ ChromaDB: {base.chromaDir || '/data/chromadb'} +

- {/* Files */} {base.files && base.files.length > 0 && (
- {base.files.map((file) => ( + {base.files.map((file: any) => (
)} - {/* Upload buttons */} -
- - +
+ + +
)} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index b705c9e..05e8197 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -41,6 +41,17 @@ export default function Sidebar() { } }; + const toggleKnowledgeBase = (baseId: string) => { + if (!currentConversation) return; + const currentIds = currentConversation.knowledgeBaseIds || []; + const newIds = currentIds.includes(baseId) + ? currentIds.filter((id) => id !== baseId) + : [...currentIds, baseId]; + updateConversation(currentConversation.id, { + knowledgeBaseIds: newIds.length > 0 ? newIds : undefined, + }); + }; + return (
{/* Header */} @@ -118,29 +129,32 @@ export default function Sidebar() { No knowledge bases yet

) : ( -
- - {currentConversation?.knowledgeBaseId && ( -

- Using: {knowledgeBases.find((b) => b.id === currentConversation.knowledgeBaseId)?.name} +

+ {knowledgeBases.map((base) => ( + + ))} + {currentConversation && (currentConversation.knowledgeBaseIds?.length || 0) > 0 && ( +

+ {currentConversation.knowledgeBaseIds?.length} base(s) active

)}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d960229..00c052e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -16,7 +16,7 @@ export interface Conversation { createdAt: number; updatedAt: number; model?: string; - knowledgeBaseId?: string | null; + knowledgeBaseIds?: string[]; } export interface ChatSettings { @@ -42,6 +42,7 @@ export interface KnowledgeBase { id: string; name: string; description: string; + chromaDir: string; files: KnowledgeFile[]; documentCount: number; createdAt: number; diff --git a/nginx/nginx.conf b/nginx/nginx.conf index e16f88d..42f6d81 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -18,7 +18,7 @@ server { # Backend API location /api/ { - proxy_pass http://backend:3002/; + proxy_pass http://backend:3002/api/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -30,14 +30,4 @@ server { proxy_cache off; proxy_read_timeout 86400s; } - - # Strapi - location /strapi/ { - proxy_pass http://strapi:1337/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } }