Add files drag and drop, add chromaDB knowledge base support.

This commit is contained in:
emil
2026-04-26 15:27:13 +03:00
parent 1ff8c733cc
commit 55d4b7dbba
16 changed files with 1921 additions and 177 deletions
+85 -82
View File
@@ -1,119 +1,122 @@
# AIUI - Web Chat Interface
# AIUI Chat
A modern, responsive web interface for AI chat with OpenAI-compatible API support and streaming responses.
AIUI Chat — это современный веб-интерфейс для общения с LLM (большими языковыми моделями), вдохновленный Open WebUI. Поддерживает любой OpenAI-compatible API (OpenAI, Ollama, LM Studio, vLLM и другие).
## Features
## Особенности
- 💬 Real-time streaming chat responses
- 🌙 Beautiful dark theme
- 📱 Fully responsive (desktop + mobile)
- 🔧 Customizable API settings (base URL, model, API key)
- 📜 Multiple conversation support
- ✨ Markdown rendering with syntax highlighting
- ⚡ Built with React + TypeScript + Tailwind CSS + Vite
- Чат с поддержкой **streaming** ответов
- Управление **историей** диалогов
- Настройки API, модели, temperature, max tokens
- Загрузка файлов в чат
- **Базы знаний** с ChromaDB — загружайте документы и получайте ответы с учетом контекста
- Адаптивный дизайн (десктоп + мобильные)
- Темная тема в стиле modern tech
## Project Structure
## Структура проекта
```
AIUI/
├── frontend/ # React frontend
├── frontend/ # React + TypeScript + Tailwind CSS
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── hooks/ # Custom hooks
│ │ ├── services/ # API services
│ │ ├── stores/ # State management (Zustand)
│ │ ├── types/ # TypeScript types
│ │ ── utils/ # Utilities
│ │ ├── components/ # UI компоненты
│ │ │ ├── chat/ # Компоненты чата
│ │ │ ├── layout/ # Layout компоненты
│ │ │ └── knowledge/ # Компоненты баз знаний
│ │ ├── services/ # API сервисы
│ │ ── stores/ # Zustand stores
│ │ ├── types/ # TypeScript типы
│ │ └── utils/ # Утилиты
│ └── package.json
├── backend/ # Express proxy server
├── backend/ # Express.js proxy сервер
│ └── src/
│ └── index.js
── package.json # Root package.json with workspace scripts
└── README.md
│ └── index.js # Основной сервер
── package.json # Root package.json
```
## Quick Start
## Быстрый старт
### Prerequisites
- Node.js 18+
- npm
### 1. Install Dependencies
### 1. Установка зависимостей
```bash
# Install root dependencies
npm install
# Install backend dependencies
cd backend && npm install && cd ..
# Install frontend dependencies
cd frontend && npm install && cd ..
```
### 2. Start the Application
Это установит зависимости для root, frontend и backend.
### 2. Настройка API
Запустите frontend и backend, затем откройте настройки (иконка шестеренки) и укажите:
- **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
### 3. Запуск
```bash
# Start both frontend and backend
npm run dev
# Запуск backend (порт 3001)
npm run dev:backend
# В другом терминале — запуск frontend (порт 3000)
npm run dev:frontend
```
Or start them separately:
Frontend запустится на `http://localhost:3000` и будет проксировать API-запросы на backend.
### Production сборка
```bash
# Terminal 1 - Backend (port 3001)
npm run backend
# Сборка frontend
npm run build:frontend
# Terminal 2 - Frontend (port 3000)
npm run frontend
# Запуск backend (раздает собранный frontend)
npm run start:backend
```
### 3. Open in Browser
## Базы знаний (Knowledge Bases)
Navigate to http://localhost:3000
Базы знаний позволяют загружать документы и использовать их как контекст для ответов LLM.
## Usage
### Как использовать:
1. **Configure API**: Click the settings icon (⚙️) in the top right to set your:
- API Base URL (e.g., `https://api.openai.com/v1`)
- API Key
- Model name (e.g., `gpt-4o-mini`, `gpt-4o`)
1. Откройте панель **Knowledge Bases** (иконка базы данных в сайдбаре)
2. Создайте новую базу знаний
3. Загрузите документы (`.txt`, `.md`, `.pdf`, `.docx`)
4. В сайдбаре выберите базу знаний для текущего диалога
5. Задавайте вопросы — LLM будет использовать контекст из документов
2. **Start Chatting**: Click "New Chat" and send a message!
### Технические детали:
3. **Mobile**: The sidebar is collapsible - use the ☰ button to toggle it.
- Документы разбиваются на **чанки** по ~500 символов с перекрытием
- Для каждого чанка генерируется **embedding** через API
- Чанки хранятся в **ChromaDB** (векторная база данных)
- При отправке сообщения запрос эмбеддится и ищутся похожие чанки через semantic search
- Найденный контекст добавляется к системному промпту
## Development
## API Endpoints
### Build for Production
Backend проксирует запросы к OpenAI-compatible API:
```bash
npm run build
```
| 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 | Поиск по базе знаний |
### Build Frontend Only
## Технологии
```bash
cd frontend && npm run build
```
## API Configuration
The interface supports any OpenAI-compatible API:
| Provider | Base URL | Example Models |
|----------|----------|----------------|
| OpenAI | `https://api.openai.com/v1` | gpt-4o-mini, gpt-4o |
| OpenRouter | `https://openrouter.ai/api/v1` | varies |
| Local (Ollama) | `http://localhost:11434/v1` | llama2, mistral |
| Custom | Your endpoint | Your models |
## Tech Stack
- **Frontend**: React 18, TypeScript, Tailwind CSS, Vite, Zustand, Lucide Icons
- **Backend**: Express, CORS, node-fetch
- **Build**: TypeScript Compiler, Vite
## License
MIT
- **Frontend**: React 18, TypeScript, Tailwind CSS, Zustand, Lucide icons
- **Backend**: Node.js, Express.js, ChromaDB, Multer
- **Build**: Vite
+568 -3
View File
@@ -1,15 +1,28 @@
{
"name": "aiui-backend",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aiui-backend",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"chromadb": "^1.8.1",
"cors": "^2.8.5",
"express": "^4.18.2"
"express": "^4.21.2",
"mammoth": "^1.9.0",
"multer": "^1.4.5-lts.2",
"pdf-parse": "^1.1.1"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/accepts": {
@@ -25,12 +38,77 @@
"node": ">= 0.6"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@@ -70,6 +148,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -108,6 +203,90 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chromadb": {
"version": "1.10.5",
"resolved": "https://registry.npmjs.org/chromadb/-/chromadb-1.10.5.tgz",
"integrity": "sha512-+IeTjjf44pKUY3vp1BacwO2tFAPcWCd64zxPZZm98dVj/kbSBeaHKB2D6eX7iRLHS1PTVASuqoR6mAJ+nrsTBg==",
"license": "Apache-2.0",
"dependencies": {
"cliui": "^8.0.1",
"isomorphic-fetch": "^3.0.0"
},
"engines": {
"node": ">=14.17.0"
},
"peerDependencies": {
"@google/generative-ai": "^0.1.1",
"cohere-ai": "^5.0.0 || ^6.0.0 || ^7.0.0",
"ollama": "^0.5.0",
"openai": "^3.0.0 || ^4.0.0",
"voyageai": "^0.0.3-1"
},
"peerDependenciesMeta": {
"@google/generative-ai": {
"optional": true
},
"cohere-ai": {
"optional": true
},
"ollama": {
"optional": true
},
"openai": {
"optional": true
},
"voyageai": {
"optional": true
}
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"engines": [
"node >= 0.8"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -144,6 +323,12 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -189,6 +374,21 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -209,6 +409,12 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -459,6 +665,12 @@
"node": ">=0.10.0"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -474,6 +686,87 @@
"node": ">= 0.10"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isomorphic-fetch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz",
"integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.1",
"whatwg-fetch": "^3.4.1"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -543,12 +836,52 @@
"node": ">= 0.6"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -558,6 +891,32 @@
"node": ">= 0.6"
}
},
"node_modules/node-ensure": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -591,6 +950,18 @@
"node": ">= 0.8"
}
},
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -600,12 +971,43 @@
"node": ">= 0.8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/pdf-parse": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.4.tgz",
"integrity": "sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==",
"license": "MIT",
"dependencies": {
"node-ensure": "^0.0.0"
},
"engines": {
"node": ">=6.8.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -658,6 +1060,27 @@
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -729,6 +1152,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -807,6 +1236,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -816,6 +1251,55 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -825,6 +1309,12 @@
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -838,6 +1328,18 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -847,6 +1349,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -864,6 +1372,63 @@
"engines": {
"node": ">= 0.8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
"license": "MIT"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
+9 -5
View File
@@ -1,14 +1,18 @@
{
"name": "aiui-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"version": "1.0.0",
"description": "AIUI Chat Backend",
"main": "src/index.js",
"scripts": {
"dev": "node src/index.js",
"dev": "node --watch src/index.js",
"start": "node src/index.js"
},
"dependencies": {
"chromadb": "^1.8.1",
"cors": "^2.8.5",
"express": "^4.18.2"
"express": "^4.21.2",
"mammoth": "^1.9.0",
"multer": "^1.4.5-lts.2",
"pdf-parse": "^1.1.1"
}
}
+384 -59
View File
@@ -1,61 +1,59 @@
import express from 'express';
import cors from 'cors';
const express = require('express');
const cors = require('cors');
const { ChromaClient } = require('chromadb');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors());
app.use(express.json());
// Health check
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
// File upload config
const uploadDir = path.join(__dirname, '../../data/uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => {
const uniqueName = Date.now() + '-' + Math.random().toString(36).substring(2, 8);
cb(null, uniqueName + path.extname(file.originalname));
},
});
// Proxy: list models
app.post('/api/v1/models', async (req, res) => {
try {
const { apiUrl, apiKey } = req.body;
const upload = multer({ storage });
if (!apiUrl || !apiKey) {
return res.status(400).json({ error: { message: 'apiUrl and apiKey are required' } });
}
// In-memory storage for knowledge bases metadata
const knowledgeBases = new Map();
const knowledgeFiles = new Map();
const uploadedFiles = new Map();
const response = await fetch(`${apiUrl}/models`, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
// Global ChromaDB client (recreated per request when path changes)
function getChromaClient(persistDir = './data/chromadb') {
return new ChromaClient({
path: `file://${path.resolve(persistDir)}`,
});
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return res.status(response.status).json(errorData);
}
// ===== OPENAI PROXY =====
const data = await response.json();
const models = data.data?.map((m) => m.id) || [];
res.json({ models });
} catch (error) {
console.error('Models fetch error:', error);
res.status(500).json({ error: { message: error.message || 'Internal server error' } });
}
});
// Proxy: chat completions with streaming
app.post('/api/v1/chat/completions', async (req, res) => {
try {
const { apiUrl, apiKey, model, temperature, max_tokens, messages, stream } = req.body;
if (!apiUrl || !apiKey || !model) {
return res.status(400).json({ error: { message: 'apiUrl, apiKey, and model are required' } });
return res.status(400).json({ error: { message: 'Missing API configuration' } });
}
const response = await fetch(`${apiUrl}/chat/completions`, {
const url = `${apiUrl.replace(/\/$/, '')}/chat/completions`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
@@ -67,42 +65,369 @@ app.post('/api/v1/chat/completions', async (req, res) => {
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return res.status(response.status).json(errorData);
const errorData = await response.text();
return res.status(response.status).json({
error: { message: `Proxy error: ${response.statusText}. ${errorData}` },
});
}
// Stream the response back to client
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
// Stream the response
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const reader = response.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(Buffer.from(value));
// Flush to ensure real-time streaming
if (res.flush) res.flush();
}
} catch (err) {
console.error('Stream read error:', err);
} finally {
reader.releaseLock();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(decoder.decode(value, { stream: true }));
}
res.end();
} catch (error) {
console.error('Chat completion error:', error);
if (!res.headersSent) {
res.status(500).json({ error: { message: error.message || 'Internal server error' } });
} else {
res.end();
}
console.error('Proxy error:', error);
res.status(500).json({
error: { message: error.message || 'Internal server error' },
});
}
});
app.post('/api/v1/models', async (req, res) => {
try {
const { apiUrl, apiKey } = req.body;
if (!apiUrl || !apiKey) {
return res.status(400).json({ error: { message: 'Missing API configuration' } });
}
const url = `${apiUrl.replace(/\/$/, '')}/models`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${apiKey}`,
},
});
if (!response.ok) {
return res.status(response.status).json({
error: { message: `Failed to fetch models: ${response.statusText}` },
});
}
const data = await response.json();
const models = data.data?.map((m) => m.id) || [];
res.json({ models });
} catch (error) {
console.error('Models error:', error);
res.status(500).json({ error: { message: error.message } });
}
});
// ===== FILE UPLOADS =====
app.post('/api/files/upload', upload.array('files', 5), (req, res) => {
try {
const files = req.files || [];
const result = files.map((f) => {
const fileInfo = {
id: f.filename,
originalName: f.originalname,
size: f.size,
mimeType: f.mimetype,
uploadedAt: Date.now(),
};
uploadedFiles.set(f.filename, fileInfo);
return fileInfo;
});
res.json({ files: result });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/files', (req, res) => {
const files = Array.from(uploadedFiles.values());
res.json({ files });
});
app.delete('/api/files/:id', (req, res) => {
const { id } = req.params;
const fileInfo = uploadedFiles.get(id);
if (fileInfo) {
const filePath = path.join(uploadDir, id);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
uploadedFiles.delete(id);
}
res.json({ success: true });
});
// ===== KNOWLEDGE BASES =====
function chunkText(text, chunkSize = 500, overlap = 50) {
const chunks = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length);
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;
}
}
return chunks;
}
async function getEmbeddings(texts, apiUrl, apiKey, model) {
const url = `${apiUrl.replace(/\/$/, '')}/embeddings`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({ model, input: texts }),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Embedding failed: ${response.status} ${err}`);
}
const data = await response.json();
return data.data.map((d) => d.embedding);
}
app.get('/api/knowledge', async (req, res) => {
const { chromaDir } = req.query;
try {
const client = getChromaClient(chromaDir || './data/chromadb');
// Get all collections to list knowledge bases
const collections = await client.listCollections();
const bases = [];
for (const colName of collections) {
const kb = knowledgeBases.get(colName);
if (kb) {
const files = knowledgeFiles.get(colName) || [];
const totalChunks = files.reduce((s, f) => s + (f.chunkCount || 0), 0);
bases.push({
...kb,
documentCount: totalChunks,
files: files,
});
}
}
res.json({ bases });
} catch (error) {
console.error('List knowledge bases error:', error);
res.status(500).json({ error: error.message });
}
});
app.post('/api/knowledge', async (req, res) => {
const { name, description, chromaDir } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Name is required' });
}
const id = name.trim().toLowerCase().replace(/[^a-z0-9]/g, '_') + '_' + Date.now();
try {
const client = getChromaClient(chromaDir || './data/chromadb');
await client.getOrCreateCollection({
name: id,
metadata: { name: name.trim(), description: description || '', createdAt: Date.now() },
});
const kb = {
id,
name: name.trim(),
description: description || '',
documentCount: 0,
files: [],
};
knowledgeBases.set(id, kb);
knowledgeFiles.set(id, []);
res.json(kb);
} catch (error) {
console.error('Create knowledge base error:', error);
res.status(500).json({ error: error.message });
}
});
app.delete('/api/knowledge/:id', async (req, res) => {
const { id } = req.params;
const { chromaDir } = req.query;
try {
const client = getChromaClient(chromaDir || './data/chromadb');
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 });
}
});
app.post('/api/knowledge/:id/files', upload.single('file'), async (req, res) => {
const { id } = req.params;
const { apiUrl, apiKey, embeddingModel } = req.body;
const file = req.file;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
if (!apiUrl || !apiKey) {
return res.status(400).json({ error: 'API URL and Key are required for embeddings' });
}
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 });
} catch (error) {
console.error('Upload to knowledge base error:', error);
res.status(500).json({ error: error.message });
}
});
app.delete('/api/knowledge/:baseId/files/:fileId', async (req, res) => {
const { baseId, fileId } = req.params;
try {
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 } },
});
if (results.ids.length > 0) {
await collection.delete({ ids: results.ids });
}
// Update metadata
const files = (knowledgeFiles.get(baseId) || []).filter((f) => f.id !== fileId);
knowledgeFiles.set(baseId, files);
const kb = knowledgeBases.get(baseId);
if (kb) {
kb.files = files;
kb.documentCount = files.reduce((s, f) => s + (f.chunkCount || 0), 0);
knowledgeBases.set(baseId, kb);
}
res.json({ success: true });
} catch (error) {
console.error('Delete file from knowledge base error:', error);
res.status(500).json({ error: error.message });
}
});
app.post('/api/knowledge/:id/query', async (req, res) => {
const { id } = req.params;
const { query, apiUrl, apiKey, embeddingModel, topK = 5 } = req.body;
if (!query) return res.status(400).json({ error: 'Query is required' });
if (!apiUrl || !apiKey) {
return res.status(400).json({ error: 'API URL and Key are required' });
}
try {
const [queryEmbedding] = await getEmbeddings([query], apiUrl, apiKey, embeddingModel || 'text-embedding-3-small');
const client = getChromaClient();
const collection = await client.getCollection({ name: id });
const results = await collection.query({
queryEmbeddings: [queryEmbedding],
nResults: Math.min(topK, 20),
include: ['documents', 'distances'],
});
const formatted = [];
if (results.documents[0]) {
for (let i = 0; i < results.documents[0].length; i++) {
formatted.push({
text: results.documents[0][i],
score: 1 - (results.distances?.[0]?.[i] || 0),
});
}
}
res.json({ results: formatted });
} catch (error) {
console.error('Query knowledge base error:', error);
res.status(500).json({ error: error.message });
}
});
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.listen(PORT, () => {
console.log(`AIUI Backend running on http://localhost:${PORT}`);
console.log(`AIUI Backend running on port ${PORT}`);
console.log(`Upload directory: ${uploadDir}`);
});
+5 -1
View File
@@ -3,6 +3,7 @@ import { useChatStore } from '@/stores/chatStore';
import Sidebar from '@/components/layout/Sidebar';
import ChatArea from '@/components/chat/ChatArea';
import SettingsModal from '@/components/chat/SettingsModal';
import KnowledgePanel from '@/components/knowledge/KnowledgePanel';
import { Menu, Settings } from 'lucide-react';
function App() {
@@ -51,7 +52,7 @@ function App() {
<div className="flex items-center gap-3">
<button
onClick={toggleSidebar}
className="p-2 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors lg:hidden"
className="p-2 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors"
>
<Menu size={20} />
</button>
@@ -79,6 +80,9 @@ function App() {
{/* Settings Modal */}
{isSettingsOpen && <SettingsModal />}
{/* Knowledge Panel */}
<KnowledgePanel />
</div>
);
}
+44 -6
View File
@@ -1,7 +1,7 @@
import { useRef, useCallback } from 'react';
import { useChatStore } from '@/stores/chatStore';
import { streamChatCompletion } from '@/services/api';
import type { Message } from '@/types';
import { streamChatCompletion, queryKnowledge } from '@/services/api';
import type { Message, UploadedFile } from '@/types';
import MessageList from './MessageList';
import ChatInput from './ChatInput';
@@ -24,9 +24,41 @@ export default function ChatArea({ conversationId }: ChatAreaProps) {
const conversation = getCurrentConversation();
messagesRef.current = conversation?.messages || [];
const buildSystemPrompt = useCallback(
async (basePrompt: string, kbId?: string | null): Promise<string> => {
if (!kbId || !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
);
if (results.length === 0) return basePrompt;
const context = results
.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.`;
} catch {
return basePrompt;
}
},
[settings]
);
const handleSendMessage = useCallback(
async (content: string) => {
if (!content.trim() || !settings.apiUrl || !settings.apiKey || !settings.model) {
async (content: string, files: UploadedFile[] = []) => {
if ((!content.trim() && files.length === 0) || !settings.apiUrl || !settings.apiKey || !settings.model) {
return;
}
@@ -39,6 +71,7 @@ export default function ChatArea({ conversationId }: ChatAreaProps) {
role: 'user',
content: content.trim(),
timestamp: Date.now(),
files: files.length > 0 ? files : undefined,
};
addMessage(conversationId, userMessage);
@@ -59,6 +92,11 @@ export default function ChatArea({ conversationId }: ChatAreaProps) {
let fullContent = '';
try {
const systemPrompt = await buildSystemPrompt(
settings.systemPrompt,
conversation?.knowledgeBaseId
);
const stream = streamChatCompletion(
[...messagesRef.current, userMessage],
settings.apiUrl,
@@ -66,7 +104,7 @@ export default function ChatArea({ conversationId }: ChatAreaProps) {
settings.model,
settings.temperature,
settings.maxTokens,
settings.systemPrompt,
systemPrompt,
abortControllerRef.current.signal
);
@@ -101,7 +139,7 @@ export default function ChatArea({ conversationId }: ChatAreaProps) {
abortControllerRef.current = null;
}
},
[conversationId, settings, addMessage, updateMessage, setLoading]
[conversationId, settings, addMessage, updateMessage, setLoading, buildSystemPrompt, conversation?.knowledgeBaseId]
);
const handleStop = useCallback(() => {
+31 -8
View File
@@ -1,26 +1,32 @@
import { useState, useRef, useCallback } from 'react';
import { Send, Square } from 'lucide-react';
import { Send, Square, Paperclip } from 'lucide-react';
import { useChatStore } from '@/stores/chatStore';
import FileUpload from './FileUpload';
import type { UploadedFile } from '@/types';
interface ChatInputProps {
onSend: (message: string) => void;
onSend: (message: string, files: UploadedFile[]) => void;
onStop: () => void;
disabled?: boolean;
}
export default function ChatInput({ onSend, onStop, disabled }: ChatInputProps) {
const [input, setInput] = useState('');
const [files, setFiles] = useState<UploadedFile[]>([]);
const [showUpload, setShowUpload] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { isLoading } = useChatStore();
const handleSubmit = useCallback(() => {
if (!input.trim() || isLoading) return;
onSend(input);
if ((!input.trim() && files.length === 0) || isLoading) return;
onSend(input, files);
setInput('');
setFiles([]);
setShowUpload(false);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}, [input, isLoading, onSend]);
}, [input, isLoading, files, onSend]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -37,14 +43,31 @@ export default function ChatInput({ onSend, onStop, disabled }: ChatInputProps)
return (
<div className="border-t border-[var(--color-border)] bg-[var(--color-bg)] p-4">
<div className="max-w-4xl mx-auto">
<div className="max-w-4xl mx-auto space-y-2">
{disabled && (
<div className="mb-2 text-xs text-center text-amber-400 bg-amber-400/10 rounded-lg py-2 px-3">
Please configure API settings (URL, Key, and Model) in the settings menu to start chatting
</div>
)}
{/* File upload area */}
{showUpload && (
<FileUpload
files={files}
onFilesChange={setFiles}
disabled={disabled || isLoading}
/>
)}
<div className="flex items-end gap-2 bg-[var(--color-surface)] border border-[var(--color-border)] rounded-2xl p-2 focus-within:ring-2 focus-within:ring-primary-500/50 focus-within:border-primary-500 transition-all">
<button
onClick={() => setShowUpload(!showUpload)}
className="p-2.5 rounded-xl text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)] transition-colors shrink-0"
title="Attach files"
>
<Paperclip size={18} />
</button>
<textarea
ref={textareaRef}
value={input}
@@ -68,7 +91,7 @@ export default function ChatInput({ onSend, onStop, disabled }: ChatInputProps)
) : (
<button
onClick={handleSubmit}
disabled={!input.trim() || disabled}
disabled={(!input.trim() && files.length === 0) || disabled}
className="p-2.5 rounded-xl bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors shrink-0"
title="Send message"
>
@@ -77,7 +100,7 @@ export default function ChatInput({ onSend, onStop, disabled }: ChatInputProps)
)}
</div>
<p className="text-center text-xs text-[var(--color-text-muted)] mt-2">
<p className="text-center text-xs text-[var(--color-text-muted)] mt-1">
Press Enter to send, Shift+Enter for new line
</p>
</div>
+146
View File
@@ -0,0 +1,146 @@
import { useState, useCallback, useRef } from 'react';
import { Upload, X, FileText, FileImage, File } from 'lucide-react';
import type { UploadedFile } from '@/types';
interface FileUploadProps {
files: UploadedFile[];
onFilesChange: (files: UploadedFile[]) => void;
disabled?: boolean;
}
export default function FileUpload({ files, onFilesChange, disabled }: FileUploadProps) {
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!disabled) setIsDragging(true);
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (disabled) return;
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length === 0) return;
await uploadFiles(droppedFiles);
}, [disabled]);
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) return;
const selectedFiles = Array.from(e.target.files);
await uploadFiles(selectedFiles);
e.target.value = '';
}, []);
const uploadFiles = async (fileList: File[]) => {
setIsUploading(true);
try {
const formData = new FormData();
fileList.forEach((f) => formData.append('files', f));
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Upload failed');
const data = await response.json();
onFilesChange([...files, ...data.files]);
} catch (err) {
console.error('Upload error:', err);
alert('Failed to upload files');
} finally {
setIsUploading(false);
}
};
const removeFile = (id: string) => {
onFilesChange(files.filter((f) => f.id !== id));
};
const getFileIcon = (mimeType: string) => {
if (mimeType.startsWith('image/')) return <FileImage size={14} />;
if (mimeType === 'application/pdf' || mimeType.includes('document')) return <FileText size={14} />;
return <File size={14} />;
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className="space-y-2">
{/* Attached files */}
{files.length > 0 && (
<div className="flex flex-wrap gap-2">
{files.map((file) => (
<div
key={file.id}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] text-xs"
>
<span className="text-[var(--color-text-secondary)]">
{getFileIcon(file.mimeType)}
</span>
<span className="text-[var(--color-text)] max-w-[120px] truncate">
{file.originalName}
</span>
<span className="text-[var(--color-text-muted)]">
{formatSize(file.size)}
</span>
<button
onClick={() => removeFile(file.id)}
className="ml-1 text-[var(--color-text-muted)] hover:text-red-400 transition-colors"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
{/* Drop zone */}
<button
onClick={() => inputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
disabled={disabled || isUploading}
className={`w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-dashed text-xs transition-all
${isDragging
? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]'
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:border-[var(--color-text-secondary)] hover:text-[var(--color-text-secondary)]'
}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
>
{isUploading ? (
<span className="animate-pulse">Uploading...</span>
) : (
<>
<Upload size={14} />
<span>Drop files or click to upload</span>
</>
)}
</button>
<input
ref={inputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
disabled={disabled || isUploading}
/>
</div>
);
}
+53 -10
View File
@@ -3,11 +3,26 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { User, Bot, AlertCircle } from 'lucide-react';
import type { Message } from '@/types';
import ThinkBlock from './ThinkBlock';
interface MessageListProps {
messages: Message[];
}
function parseThinkContent(content: string): { think: string | null; body: string } {
const thinkRegex = /<think>([\s\S]*?)<\/think>/;
const match = content.match(thinkRegex);
if (!match) return { think: null, body: content };
const think = match[1].trim();
const body = content.replace(thinkRegex, '').trim();
return { think, body };
}
interface ParsedMessage extends Omit<Message, 'thinkContent'> {
thinkContent: string | null;
displayContent: string;
}
export default function MessageList({ messages }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -23,6 +38,14 @@ export default function MessageList({ messages }: MessageListProps) {
});
};
const parsedMessages: ParsedMessage[] = messages.map((msg) => {
if (msg.role === 'assistant') {
const { think, body } = parseThinkContent(msg.content);
return { ...msg, thinkContent: think, displayContent: body };
}
return { ...msg, thinkContent: null, displayContent: msg.content };
});
return (
<div
ref={containerRef}
@@ -41,7 +64,7 @@ export default function MessageList({ messages }: MessageListProps) {
</div>
</div>
) : (
messages.map((message) => (
parsedMessages.map((message) => (
<div
key={message.id}
className={`flex gap-4 ${
@@ -77,16 +100,36 @@ export default function MessageList({ messages }: MessageListProps) {
}`}
>
{message.role === 'user' ? (
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
<div className="space-y-2">
{message.files && message.files.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{message.files.map((f) => (
<span
key={f.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-white/20 text-xs"
>
{f.originalName}
</span>
))}
</div>
)}
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
</div>
) : (
<div className="markdown-body text-sm">
{message.content ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
) : message.isStreaming ? (
<span className="animate-pulse"></span>
) : null}
<div className="space-y-1">
{/* Think block */}
{message.thinkContent && (
<ThinkBlock content={message.thinkContent} />
)}
<div className="markdown-body text-sm">
{message.displayContent ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.displayContent}
</ReactMarkdown>
) : message.isStreaming ? (
<span className="animate-pulse"></span>
) : null}
</div>
</div>
)}
@@ -216,6 +216,46 @@ export default function SettingsModal() {
className="input-field resize-none"
/>
</div>
<hr className="border-[var(--color-border)]" />
{/* Embedding Model */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
Embedding Model
</label>
<input
type="text"
value={localSettings.embeddingModel}
onChange={(e) =>
setLocalSettings((prev) => ({ ...prev, embeddingModel: e.target.value }))
}
placeholder="text-embedding-3-small"
className="input-field"
/>
<p className="text-xs text-[var(--color-text-muted)]">
Model used for knowledge base embeddings
</p>
</div>
{/* ChromaDB Directory */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
ChromaDB Directory
</label>
<input
type="text"
value={localSettings.chromaDir}
onChange={(e) =>
setLocalSettings((prev) => ({ ...prev, chromaDir: e.target.value }))
}
placeholder="./data/chromadb"
className="input-field"
/>
<p className="text-xs text-[var(--color-text-muted)]">
Path to ChromaDB persistent storage on the server
</p>
</div>
</div>
{/* Footer */}
@@ -0,0 +1,36 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, Brain } from 'lucide-react';
interface ThinkBlockProps {
content: string;
}
export default function ThinkBlock({ content }: ThinkBlockProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="my-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--color-surface-hover)] transition-colors"
>
<Brain size={14} className="text-[var(--color-accent)]" />
<span className="text-xs font-medium text-[var(--color-text-secondary)] flex-1">
Reasoning
</span>
{isOpen ? (
<ChevronDown size={14} className="text-[var(--color-text-muted)]" />
) : (
<ChevronRight size={14} className="text-[var(--color-text-muted)]" />
)}
</button>
{isOpen && (
<div className="px-3 py-2 border-t border-[var(--color-border)]">
<pre className="text-xs text-[var(--color-text-secondary)] whitespace-pre-wrap font-mono leading-relaxed">
{content}
</pre>
</div>
)}
</div>
);
}
@@ -0,0 +1,282 @@
import { useState, useEffect } from 'react';
import { X, Plus, Trash2, Upload, BookOpen, FileText, Database, ChevronDown, ChevronUp } from 'lucide-react';
import { useChatStore } from '@/stores/chatStore';
export default function KnowledgePanel() {
const { settings, toggleKnowledge, isKnowledgeOpen, setKnowledgeBases, knowledgeBases, removeKnowledgeBase } = useChatStore();
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [expandedBase, setExpandedBase] = useState<string | null>(null);
const [uploadingBase, setUploadingBase] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isKnowledgeOpen) loadBases();
}, [isKnowledgeOpen]);
const loadBases = async () => {
try {
const res = await fetch('/api/knowledge');
const data = await res.json();
setKnowledgeBases(data.bases || []);
} catch (err) {
console.error('Failed to load knowledge bases:', err);
}
};
const handleCreate = async () => {
if (!newName.trim()) return;
setLoading(true);
try {
const res = await fetch('/api/knowledge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
});
const base = await res.json();
setKnowledgeBases([...knowledgeBases, base]);
setNewName('');
setNewDesc('');
setIsCreating(false);
} catch (err) {
alert('Failed to create knowledge base');
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Delete this knowledge base?')) return;
try {
await fetch(`/api/knowledge/${id}`, { method: 'DELETE' });
removeKnowledgeBase(id);
} catch (err) {
alert('Failed to delete knowledge base');
}
};
const handleUploadFile = async (baseId: string, e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.[0]) return;
const file = e.target.files[0];
setUploadingBase(baseId);
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();
// Update local state
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) {
alert('Failed to upload file. Make sure API settings are configured.');
} finally {
setUploadingBase(null);
e.target.value = '';
}
};
const handleDeleteFile = async (baseId: string, fileId: string) => {
try {
await fetch(`/api/knowledge/${baseId}/files/${fileId}`, { method: 'DELETE' });
const updated = knowledgeBases.map((b) =>
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),
}
: b
);
setKnowledgeBases(updated);
} catch (err) {
alert('Failed to delete file');
}
};
if (!isKnowledgeOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={toggleKnowledge} />
<div className="relative bg-[var(--color-surface)] border border-[var(--color-border)] rounded-2xl w-full max-w-2xl max-h-[85vh] overflow-hidden shadow-2xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-[var(--color-border)] shrink-0">
<div className="flex items-center gap-2.5">
<Database size={20} className="text-[var(--color-accent)]" />
<h2 className="text-lg font-semibold text-[var(--color-text)]">Knowledge Bases</h2>
</div>
<button
onClick={toggleKnowledge}
className="p-2 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{/* Create new */}
{!isCreating ? (
<button
onClick={() => setIsCreating(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl border-2 border-dashed border-[var(--color-border)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] transition-colors"
>
<Plus size={18} />
<span className="text-sm font-medium">Create Knowledge Base</span>
</button>
) : (
<div className="space-y-3 p-4 rounded-xl bg-[var(--color-bg)] border border-[var(--color-border)]">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Name"
className="input-field"
/>
<input
type="text"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
placeholder="Description (optional)"
className="input-field"
/>
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={loading || !newName.trim()}
className="btn-primary flex-1"
>
{loading ? 'Creating...' : 'Create'}
</button>
<button
onClick={() => { setIsCreating(false); setNewName(''); setNewDesc(''); }}
className="px-4 py-2 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{/* List */}
{knowledgeBases.length === 0 ? (
<div className="text-center py-12 text-[var(--color-text-muted)]">
<BookOpen size={40} className="mx-auto mb-3 opacity-40" />
<p className="text-sm">No knowledge bases yet</p>
<p className="text-xs mt-1">Create one to start adding documents</p>
</div>
) : (
<div className="space-y-3">
{knowledgeBases.map((base) => (
<div
key={base.id}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] overflow-hidden"
>
<div className="flex items-center justify-between px-4 py-3">
<button
onClick={() => setExpandedBase(expandedBase === base.id ? null : base.id)}
className="flex items-center gap-2 text-left flex-1 min-w-0"
>
{expandedBase === base.id ? (
<ChevronUp size={16} className="text-[var(--color-text-muted)] shrink-0" />
) : (
<ChevronDown size={16} className="text-[var(--color-text-muted)] shrink-0" />
)}
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--color-text)] truncate">
{base.name}
</p>
<p className="text-xs text-[var(--color-text-muted)]">
{base.documentCount || 0} chunks · {base.files.length} files
</p>
</div>
</button>
<button
onClick={() => handleDelete(base.id)}
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
>
<Trash2 size={14} />
</button>
</div>
{expandedBase === base.id && (
<div className="px-4 pb-4 border-t border-[var(--color-border)] pt-3 space-y-3">
{base.description && (
<p className="text-xs text-[var(--color-text-muted)]">{base.description}</p>
)}
{/* Files */}
{base.files.length > 0 && (
<div className="space-y-1.5">
{base.files.map((file) => (
<div
key={file.id}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-[var(--color-surface)] text-xs"
>
<div className="flex items-center gap-2 min-w-0">
<FileText size={12} className="text-[var(--color-text-secondary)] shrink-0" />
<span className="text-[var(--color-text)] truncate">
{file.originalName}
</span>
<span className="text-[var(--color-text-muted)] shrink-0">
({file.chunkCount} chunks)
</span>
</div>
<button
onClick={() => handleDeleteFile(base.id, file.id)}
className="p-1 rounded text-[var(--color-text-muted)] hover:text-red-400 transition-colors shrink-0"
>
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
{/* Upload */}
<div className="relative">
<label className={`flex items-center justify-center gap-2 w-full px-3 py-2 rounded-lg border border-dashed border-[var(--color-border)] text-xs text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] transition-colors cursor-pointer ${uploadingBase === base.id ? 'opacity-50' : ''}`}>
{uploadingBase === base.id ? (
<span className="animate-pulse">Processing...</span>
) : (
<>
<Upload size={14} />
<span>Upload document</span>
</>
)}
</label>
<input
type="file"
accept=".txt,.md,.pdf,.docx"
onChange={(e) => handleUploadFile(base.id, e)}
disabled={uploadingBase === base.id}
className="hidden"
/>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
+56 -1
View File
@@ -1,5 +1,5 @@
import { useChatStore } from '@/stores/chatStore';
import { Plus, Trash2, MessageSquare, ChevronLeft } from 'lucide-react';
import { Plus, Trash2, MessageSquare, ChevronLeft, Database } from 'lucide-react';
export default function Sidebar() {
const {
@@ -9,8 +9,14 @@ export default function Sidebar() {
selectConversation,
deleteConversation,
toggleSidebar,
toggleKnowledge,
knowledgeBases,
getCurrentConversation,
updateConversation,
} = useChatStore();
const currentConversation = getCurrentConversation();
const handleDelete = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (confirm('Delete this conversation?')) {
@@ -92,6 +98,55 @@ export default function Sidebar() {
)}
</div>
{/* Knowledge Bases */}
<div className="border-t border-[var(--color-border)] p-3 space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)] uppercase tracking-wider">
Knowledge
</h3>
<button
onClick={toggleKnowledge}
className="p-1 rounded-md hover:bg-[var(--color-surface-hover)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors"
title="Manage Knowledge Bases"
>
<Database size={14} />
</button>
</div>
{knowledgeBases.length === 0 ? (
<p className="text-xs text-[var(--color-text-muted)] px-1">
No knowledge bases yet
</p>
) : (
<div className="space-y-1">
<select
value={currentConversation?.knowledgeBaseId || ''}
onChange={(e) => {
if (currentConversation) {
updateConversation(currentConversation.id, {
knowledgeBaseId: e.target.value || undefined,
});
}
}}
disabled={!currentConversation}
className="w-full text-xs bg-[var(--color-bg)] border border-[var(--color-border)] rounded-lg px-2 py-1.5 text-[var(--color-text)] outline-none focus:ring-1 focus:ring-[var(--color-accent)] disabled:opacity-50"
>
<option value="">No knowledge base</option>
{knowledgeBases.map((base) => (
<option key={base.id} value={base.id}>
{base.name}
</option>
))}
</select>
{currentConversation?.knowledgeBaseId && (
<p className="text-[10px] text-[var(--color-accent)] px-1">
Using: {knowledgeBases.find((b) => b.id === currentConversation.knowledgeBaseId)?.name}
</p>
)}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-[var(--color-border)]">
<button
+112 -1
View File
@@ -1,4 +1,4 @@
import type { Message, OpenAIMessage, OpenAIStreamChunk } from '@/types';
import type { Message, OpenAIMessage, OpenAIStreamChunk, KnowledgeBase } from '@/types';
export async function* streamChatCompletion(
messages: Message[],
@@ -92,3 +92,114 @@ export async function fetchModels(
const data = await response.json();
return data.models || [];
}
// ===== FILES =====
export async function uploadFiles(files: FileList): Promise<{ files: Array<{ id: string; originalName: string; size: number; mimeType: string; uploadedAt: number }> }> {
const formData = new FormData();
for (const file of files) {
formData.append('files', file);
}
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Upload failed: ${response.status}`);
}
return response.json();
}
export async function listFiles(): Promise<{ files: Array<{ id: string; originalName: string; size: number; uploadedAt: number }> }> {
const response = await fetch('/api/files');
if (!response.ok) throw new Error('Failed to list files');
return response.json();
}
export async function deleteFile(id: string): Promise<void> {
const response = await fetch(`/api/files/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete file');
}
// ===== KNOWLEDGE BASES =====
export async function listKnowledgeBases(): Promise<{ bases: KnowledgeBase[] }> {
const response = await fetch('/api/knowledge');
if (!response.ok) throw new Error('Failed to list knowledge bases');
return response.json();
}
export async function createKnowledgeBase(name: string, description?: string): Promise<KnowledgeBase> {
const response = await fetch('/api/knowledge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to create knowledge base');
}
return response.json();
}
export async function deleteKnowledgeBase(id: string): Promise<void> {
const response = await fetch(`/api/knowledge/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete knowledge base');
}
export async function uploadFileToKnowledge(
baseId: string,
file: File,
apiUrl: string,
apiKey: string,
embeddingModel: string
): Promise<{ file: { id: string; originalName: string; size: number; chunkCount: number }; chunks: number }> {
const formData = new FormData();
formData.append('file', file);
formData.append('apiUrl', apiUrl);
formData.append('apiKey', apiKey);
formData.append('embeddingModel', embeddingModel);
const response = await fetch(`/api/knowledge/${baseId}/files`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to upload file to knowledge base');
}
return response.json();
}
export async function deleteFileFromKnowledge(baseId: string, fileId: string): Promise<void> {
const response = await fetch(`/api/knowledge/${baseId}/files/${fileId}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete file from knowledge base');
}
export async function queryKnowledge(
baseId: string,
query: string,
apiUrl: string,
apiKey: string,
embeddingModel: string,
topK = 5
): Promise<{ results: Array<{ text: string; score: number }> }> {
const response = await fetch(`/api/knowledge/${baseId}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, apiUrl, apiKey, embeddingModel, topK }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to query knowledge base');
}
return response.json();
}
+39 -1
View File
@@ -1,14 +1,16 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Conversation, Message, ChatSettings } from '@/types';
import type { Conversation, Message, ChatSettings, KnowledgeBase } from '@/types';
interface ChatState {
conversations: Conversation[];
currentConversationId: string | null;
isSidebarOpen: boolean;
isSettingsOpen: boolean;
isKnowledgeOpen: boolean;
isLoading: boolean;
settings: ChatSettings;
knowledgeBases: KnowledgeBase[];
// Actions
createConversation: () => string;
@@ -17,12 +19,17 @@ interface ChatState {
updateConversationTitle: (id: string, title: string) => void;
addMessage: (conversationId: string, message: Message) => void;
updateMessage: (conversationId: string, messageId: string, updates: Partial<Message>) => void;
updateConversation: (conversationId: string, updates: Partial<Conversation>) => void;
toggleSidebar: () => void;
closeSettings: () => void;
toggleSettings: () => void;
toggleKnowledge: () => void;
setLoading: (loading: boolean) => void;
updateSettings: (settings: Partial<ChatSettings>) => void;
getCurrentConversation: () => Conversation | undefined;
setKnowledgeBases: (bases: KnowledgeBase[]) => void;
addKnowledgeBase: (base: KnowledgeBase) => void;
removeKnowledgeBase: (id: string) => void;
}
const generateId = () => Date.now().toString(36) + Math.random().toString(36).substr(2);
@@ -34,6 +41,8 @@ const defaultSettings: ChatSettings = {
temperature: 0.7,
maxTokens: 4096,
systemPrompt: 'You are a helpful assistant.',
chromaDir: './data/chromadb',
embeddingModel: 'text-embedding-3-small',
};
export const useChatStore = create<ChatState>()(
@@ -43,8 +52,10 @@ export const useChatStore = create<ChatState>()(
currentConversationId: null,
isSidebarOpen: true,
isSettingsOpen: false,
isKnowledgeOpen: false,
isLoading: false,
settings: defaultSettings,
knowledgeBases: [],
createConversation: () => {
const id = generateId();
@@ -121,6 +132,14 @@ export const useChatStore = create<ChatState>()(
}));
},
updateConversation: (conversationId, updates) => {
set((state) => ({
conversations: state.conversations.map((c) =>
c.id === conversationId ? { ...c, ...updates, updatedAt: Date.now() } : c
),
}));
},
toggleSidebar: () => {
set((state) => ({ isSidebarOpen: !state.isSidebarOpen }));
},
@@ -133,6 +152,10 @@ export const useChatStore = create<ChatState>()(
set((state) => ({ isSettingsOpen: !state.isSettingsOpen }));
},
toggleKnowledge: () => {
set((state) => ({ isKnowledgeOpen: !state.isKnowledgeOpen }));
},
setLoading: (loading) => {
set({ isLoading: loading });
},
@@ -147,6 +170,20 @@ export const useChatStore = create<ChatState>()(
const { conversations, currentConversationId } = get();
return conversations.find((c) => c.id === currentConversationId);
},
setKnowledgeBases: (bases) => {
set({ knowledgeBases: bases });
},
addKnowledgeBase: (base) => {
set((state) => ({ knowledgeBases: [...state.knowledgeBases, base] }));
},
removeKnowledgeBase: (id) => {
set((state) => ({
knowledgeBases: state.knowledgeBases.filter((b) => b.id !== id),
}));
},
}),
{
name: 'aiui-chat-storage',
@@ -155,6 +192,7 @@ export const useChatStore = create<ChatState>()(
currentConversationId: state.currentConversationId,
settings: state.settings,
isSidebarOpen: state.isSidebarOpen,
knowledgeBases: state.knowledgeBases,
}),
}
)
+31
View File
@@ -5,6 +5,8 @@ export interface Message {
timestamp: number;
isStreaming?: boolean;
error?: string;
files?: UploadedFile[];
thinkContent?: string;
}
export interface Conversation {
@@ -14,6 +16,7 @@ export interface Conversation {
createdAt: number;
updatedAt: number;
model?: string;
knowledgeBaseId?: string | null;
}
export interface ChatSettings {
@@ -23,6 +26,34 @@ export interface ChatSettings {
temperature: number;
maxTokens: number;
systemPrompt: string;
chromaDir: string;
embeddingModel: string;
}
export interface UploadedFile {
id: string;
originalName: string;
size: number;
mimeType: string;
uploadedAt: number;
}
export interface KnowledgeBase {
id: string;
name: string;
description: string;
files: KnowledgeFile[];
documentCount: number;
createdAt: number;
}
export interface KnowledgeFile {
id: string;
originalName: string;
size: number;
mimeType: string;
chunkCount: number;
uploadedAt: number;
}
export interface OpenAIMessage {