Initial commit
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# .env
|
||||
DATABASE_URL=postgresql://vecuser:vecpass@postgres:5432/vectordb
|
||||
LITELLM_PROXY_URL=http://litellm:4000
|
||||
LITELLM_MASTER_KEY=sk-master-key
|
||||
|
||||
# Komma-getrennte Admin User IDs
|
||||
ADMIN_USER_IDS=admin-1,admin-2
|
||||
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --upgrade pip && \
|
||||
pip wheel --no-cache-dir --wheel-dir /build/wheels -r requirements.txt
|
||||
|
||||
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /build/wheels /wheels
|
||||
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* \
|
||||
&& rm -rf /wheels
|
||||
|
||||
COPY app/__init__.py ./app/__init__.py
|
||||
COPY app/main.py ./app/main.py
|
||||
COPY app/auth.py ./app/auth.py
|
||||
COPY app/database.py ./app/database.py
|
||||
COPY app/models.py ./app/models.py
|
||||
COPY app/routers/__init__.py ./app/routers/__init__.py
|
||||
COPY app/routers/stores.py ./app/routers/stores.py
|
||||
COPY app/routers/documents.py ./app/routers/documents.py
|
||||
COPY app/routers/admin.py ./app/routers/admin.py
|
||||
COPY app/routers/openai_compat.py ./app/routers/openai_compat.py
|
||||
COPY app/utils/__init__.py ./app/utils/__init__.py
|
||||
COPY app/utils/stats.py ./app/utils/stats.py
|
||||
COPY app/utils/chunking.py ./app/utils/chunking.py
|
||||
|
||||
RUN find /app -type f | sort
|
||||
|
||||
RUN chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import httpx; httpx.get('http://localhost:8000/health')" || exit 1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", \
|
||||
"--host", "0.0.0.0", \
|
||||
"--port", "8000", \
|
||||
"--workers", "4", \
|
||||
"--loop", "uvloop", \
|
||||
"--http", "httptools"]
|
||||
354
README.md
Normal file
354
README.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# litellm-vector-store
|
||||
|
||||
A vector store service built on top of [LiteLLM](https://github.com/BerriAI/litellm) and [pgvector](https://github.com/pgvector/pgvector), providing an OpenAI-compatible API for semantic search, document storage and Retrieval Augmented Generation (RAG).
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **Authentication** via LiteLLM API Keys
|
||||
- 🗄️ **Vector Store** powered by PostgreSQL + pgvector
|
||||
- 🔍 **Semantic Search** with optional Reranking
|
||||
- 🤖 **RAG Endpoint** - Search + LLM in one request
|
||||
- 📄 **File Upload** - PDF, DOCX, TXT, Markdown
|
||||
- 🧩 **OpenAI-compatible API** - works with existing OpenAI SDKs
|
||||
- 👥 **Multi-User** - Store permissions per user
|
||||
- 🖥️ **Admin UI** - Manage users, stores and permissions
|
||||
- 📊 **Usage Tracking** - Track requests per user
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Client (API Key)
|
||||
│
|
||||
▼
|
||||
LiteLLM Proxy ──────────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
Vector Store API Embedding Models
|
||||
│ (via LiteLLM)
|
||||
▼
|
||||
PostgreSQL + pgvector
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Kubernetes Cluster
|
||||
- PostgreSQL with pgvector extension
|
||||
- LiteLLM Proxy (deployed)
|
||||
- Container Registry
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-org/litellm-vector-store.git
|
||||
cd litellm-vector-store
|
||||
```
|
||||
|
||||
### 2. Database Setup
|
||||
|
||||
```bash
|
||||
kubectl exec -it <postgres-pod> -n <namespace> \
|
||||
-- psql -U postgres -d vectordb << 'EOF'
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vector_stores (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
owner_user_id VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
store_id UUID REFERENCES vector_stores(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
embedding vector(1024),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS store_permissions (
|
||||
store_id UUID REFERENCES vector_stores(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
permission VARCHAR(50) DEFAULT 'read',
|
||||
PRIMARY KEY (store_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_stats (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
store_id UUID REFERENCES vector_stores(id) ON DELETE SET NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
tokens INT DEFAULT 0,
|
||||
duration FLOAT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_store
|
||||
ON documents(store_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_embedding
|
||||
ON documents USING ivfflat (embedding vector_cosine_ops)
|
||||
WITH (lists = 100);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_user
|
||||
ON usage_stats(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_created
|
||||
ON usage_stats(created_at);
|
||||
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO vecuser;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO vecuser;
|
||||
|
||||
EOF
|
||||
```
|
||||
|
||||
### 3. Configure
|
||||
|
||||
```bash
|
||||
kubectl create secret generic vector-api-secrets \
|
||||
--namespace vector-store \
|
||||
--from-literal=DATABASE_URL="postgresql://vecuser:pass@postgres:5432/vectordb" \
|
||||
--from-literal=LITELLM_MASTER_KEY="sk-master-key"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# k8s/configmap.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: vector-store-config
|
||||
namespace: vector-store
|
||||
data:
|
||||
LITELLM_PROXY_URL: "http://litellm.<namespace>.svc.cluster.local:4000"
|
||||
ADMIN_USER_IDS: "your-admin-user-id"
|
||||
API_URL: "https://api.your-domain.com"
|
||||
EMBEDDING_MODEL: "your-embedding-model"
|
||||
```
|
||||
|
||||
### 4. Build & Deploy
|
||||
|
||||
```bash
|
||||
# API
|
||||
docker build -t your-registry/vector-store-api:1.0.0 .
|
||||
docker push your-registry/vector-store-api:1.0.0
|
||||
|
||||
# Admin UI
|
||||
docker build \
|
||||
-t your-registry/vector-store-admin:1.0.0 \
|
||||
./ui
|
||||
docker push your-registry/vector-store-admin:1.0.0
|
||||
|
||||
# Deploy
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/configmap.yaml
|
||||
kubectl apply -f k8s/secrets.yaml
|
||||
kubectl apply -f k8s/vector-api/
|
||||
kubectl apply -f k8s/admin-ui/
|
||||
kubectl apply -f k8s/ingress-api.yaml
|
||||
kubectl apply -f k8s/ingress-ui.yaml
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
litellm-vector-store/
|
||||
├── app/ # FastAPI Backend
|
||||
│ ├── main.py # Application entry point
|
||||
│ ├── auth.py # LiteLLM authentication
|
||||
│ ├── database.py # PostgreSQL connection
|
||||
│ ├── models.py # Pydantic models
|
||||
│ ├── routers/
|
||||
│ │ ├── stores.py # Vector store CRUD
|
||||
│ │ ├── documents.py # Document management
|
||||
│ │ ├── admin.py # Admin endpoints
|
||||
│ │ └── openai_compat.py # OpenAI-compatible API
|
||||
│ └── utils/
|
||||
│ ├── chunking.py # Text chunking
|
||||
│ └── stats.py # Usage tracking
|
||||
├── ui/ # React Admin UI
|
||||
│ ├── src/
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── Login.tsx
|
||||
│ │ │ ├── Dashboard.tsx
|
||||
│ │ │ ├── Users.tsx
|
||||
│ │ │ └── Stores.tsx
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── Layout.tsx
|
||||
│ │ │ └── PermissionModal.tsx
|
||||
│ │ └── api/
|
||||
│ │ └── client.ts
|
||||
│ └── Dockerfile
|
||||
├── k8s/ # Kubernetes manifests
|
||||
│ ├── namespace.yaml
|
||||
│ ├── configmap.yaml
|
||||
│ ├── secrets.yaml
|
||||
│ ├── vector-api/
|
||||
│ │ ├── deployment.yaml
|
||||
│ │ └── service.yaml
|
||||
│ ├── admin-ui/
|
||||
│ │ ├── deployment.yaml
|
||||
│ │ └── service.yaml
|
||||
│ ├── ingress-api.yaml
|
||||
│ └── ingress-ui.yaml
|
||||
├── scripts/
|
||||
│ └── init.sql # Database initialization
|
||||
├── Dockerfile
|
||||
├── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Base URL
|
||||
|
||||
```
|
||||
https://api.your-domain.com/v1
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
```
|
||||
Authorization: Bearer sk-your-api-key
|
||||
```
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/v1/vector_stores` | Create store |
|
||||
| `GET` | `/v1/vector_stores` | List stores |
|
||||
| `GET` | `/v1/vector_stores/{id}` | Get store |
|
||||
| `DELETE` | `/v1/vector_stores/{id}` | Delete store |
|
||||
| `POST` | `/v1/vector_stores/{id}/files` | Add texts |
|
||||
| `GET` | `/v1/vector_stores/{id}/files` | List files |
|
||||
| `DELETE` | `/v1/vector_stores/{id}/files/{file_id}` | Delete file |
|
||||
| `POST` | `/v1/vector_stores/{id}/upload` | Upload file |
|
||||
| `POST` | `/v1/vector_stores/{id}/search` | Search |
|
||||
| `POST` | `/v1/vector_stores/{id}/rag` | RAG query |
|
||||
| `POST` | `/v1/embeddings` | Create embeddings |
|
||||
| `GET` | `/v1/embeddings/models` | List embedding models |
|
||||
| `GET` | `/v1/models` | List all models |
|
||||
|
||||
### Example
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
client = httpx.Client(
|
||||
base_url="https://api.your-domain.com/v1",
|
||||
headers={"Authorization": "Bearer sk-your-key"}
|
||||
)
|
||||
|
||||
# Create store
|
||||
store = client.post(
|
||||
"/vector_stores",
|
||||
json={"name": "My Knowledge Base"}
|
||||
).json()
|
||||
|
||||
# Upload file
|
||||
with open("document.pdf", "rb") as f:
|
||||
client.post(
|
||||
f"/vector_stores/{store['id']}/upload",
|
||||
files={"file": f}
|
||||
)
|
||||
|
||||
# Search
|
||||
results = client.post(
|
||||
f"/vector_stores/{store['id']}/search",
|
||||
json={"query": "What is FastAPI?", "top_k": 3}
|
||||
).json()
|
||||
|
||||
# RAG
|
||||
answer = client.post(
|
||||
f"/vector_stores/{store['id']}/rag",
|
||||
json={"query": "What is FastAPI?"}
|
||||
).json()
|
||||
print(answer["answer"])
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `DATABASE_URL` | ✅ | — | PostgreSQL connection URL |
|
||||
| `LITELLM_PROXY_URL` | ✅ | — | LiteLLM proxy URL |
|
||||
| `LITELLM_MASTER_KEY` | ✅ | — | LiteLLM master key |
|
||||
| `ADMIN_USER_IDS` | ✅ | — | Comma-separated admin user IDs |
|
||||
| `EMBEDDING_MODEL` | ❌ | `text-embedding-ada-002` | Default embedding model |
|
||||
|
||||
### Supported File Formats
|
||||
|
||||
| Format | Extension | Notes |
|
||||
|--------|-----------|-------|
|
||||
| Text | `.txt` | UTF-8 encoded |
|
||||
| PDF | `.pdf` | Text PDFs only, no scans |
|
||||
| Word | `.docx` | Microsoft Word 2007+ |
|
||||
| Markdown | `.md` | Standard Markdown |
|
||||
|
||||
### Limits
|
||||
|
||||
| Limit | Value |
|
||||
|-------|-------|
|
||||
| Max file size | 256 MB |
|
||||
| Max search results | 50 |
|
||||
| Request timeout | 600 seconds |
|
||||
| Default chunk size | 512 characters |
|
||||
| Default chunk overlap | 50 characters |
|
||||
|
||||
## Admin UI
|
||||
|
||||
The Admin UI is available at `https://admin.your-domain.com`.
|
||||
|
||||
Login with your Admin API Key to:
|
||||
|
||||
- 📊 View usage statistics
|
||||
- 👥 Manage users and their stores
|
||||
- 🔑 Rotate API keys
|
||||
- 🔒 Grant/revoke store permissions
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run locally
|
||||
DATABASE_URL="postgresql://..." \
|
||||
LITELLM_PROXY_URL="http://..." \
|
||||
LITELLM_MASTER_KEY="sk-..." \
|
||||
ADMIN_USER_IDS="your-id" \
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# Run UI locally
|
||||
cd ui
|
||||
npm install
|
||||
VITE_API_URL=http://localhost:8000 npm run dev
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| **API** | FastAPI + Python 3.12 |
|
||||
| **Database** | PostgreSQL 16 + pgvector |
|
||||
| **Auth** | LiteLLM Key Management |
|
||||
| **Embeddings** | Via LiteLLM Proxy |
|
||||
| **Admin UI** | React + TypeScript + Tailwind CSS |
|
||||
| **Container** | Docker + Kubernetes |
|
||||
| **Ingress** | NGINX Ingress Controller |
|
||||
| **TLS** | cert-manager + Let's Encrypt |
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch (`git checkout -b feature/my-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add my feature'`)
|
||||
4. Push to the branch (`git push origin feature/my-feature`)
|
||||
5. Open a Pull Request
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
53
app/auth.py
Normal file
53
app/auth.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import httpx
|
||||
import os
|
||||
import logging
|
||||
from fastapi import HTTPException, Header
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LITELLM_URL = os.getenv("LITELLM_PROXY_URL", "http://litellm:4000")
|
||||
MASTER_KEY = os.getenv("LITELLM_MASTER_KEY")
|
||||
|
||||
async def verify_api_key(authorization: str = Header(...)) -> dict:
|
||||
token = authorization.removeprefix("Bearer ")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
# Master Key nutzen um Key-Info abzufragen
|
||||
resp = await client.get(
|
||||
f"{LITELLM_URL}/key/info",
|
||||
headers={
|
||||
"Authorization": f"Bearer {MASTER_KEY}"
|
||||
},
|
||||
params={"key": token},
|
||||
timeout=5.0
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"LiteLLM nicht erreichbar: {e}")
|
||||
raise HTTPException(503, f"Auth service unavailable: {e}")
|
||||
|
||||
logger.debug(f"LiteLLM Status: {resp.status_code}")
|
||||
logger.debug(f"LiteLLM Response: {resp.text}")
|
||||
|
||||
if resp.status_code == 404:
|
||||
raise HTTPException(401, "Invalid API Key")
|
||||
if resp.status_code == 401:
|
||||
raise HTTPException(401, "Invalid API Key")
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(502, f"Auth service error: {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
|
||||
user_id = (
|
||||
data.get("info", {}).get("user_id") or
|
||||
data.get("user_id")
|
||||
)
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(400, "API Key hat keine user_id")
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"token": token,
|
||||
"key_alias": data.get("info", {}).get("key_alias"),
|
||||
}
|
||||
32
app/database.py
Normal file
32
app/database.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import asyncpg
|
||||
import os
|
||||
from tenacity import retry, stop_after_attempt, wait_fixed
|
||||
|
||||
pool: asyncpg.Pool = None
|
||||
|
||||
@retry(stop=stop_after_attempt(5), wait=wait_fixed(3))
|
||||
async def init_db():
|
||||
global pool
|
||||
|
||||
url = os.getenv("DATABASE_URL")
|
||||
if not url:
|
||||
raise ValueError("DATABASE_URL nicht gesetzt!")
|
||||
|
||||
pool = await asyncpg.create_pool(
|
||||
dsn=url,
|
||||
min_size=2,
|
||||
max_size=10
|
||||
)
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
|
||||
print(f"✅ Datenbank verbunden")
|
||||
|
||||
async def close_db():
|
||||
global pool
|
||||
if pool:
|
||||
await pool.close()
|
||||
|
||||
async def get_db():
|
||||
async with pool.acquire() as conn:
|
||||
yield conn
|
||||
35
app/main.py
Normal file
35
app/main.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from app.database import init_db, close_db
|
||||
from app.routers import stores, documents, admin, openai_compat
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
yield
|
||||
await close_db()
|
||||
|
||||
app = FastAPI(
|
||||
title="Vector Store API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://admin.vector.cosair.de"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(stores.router, prefix="/stores", tags=["Stores"])
|
||||
app.include_router(documents.router, prefix="/documents", tags=["Documents"])
|
||||
app.include_router(admin.router, prefix="/admin", tags=["Admin"])
|
||||
|
||||
app.include_router(openai_compat.router, prefix="/v1", tags=["OpenAI Compatible"])
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
35
app/middleware/rate_limit.py
Normal file
35
app/middleware/rate_limit.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from fastapi import Request, HTTPException
|
||||
from collections import defaultdict
|
||||
import time
|
||||
|
||||
# Einfaches In-Memory Rate Limiting
|
||||
request_counts: dict = defaultdict(list)
|
||||
|
||||
RATE_LIMITS = {
|
||||
"search": (100, 60), # 100 Requests pro 60 Sekunden
|
||||
"upsert": (50, 60),
|
||||
"embed": (200, 60),
|
||||
"rag": (20, 60),
|
||||
}
|
||||
|
||||
def check_rate_limit(user_id: str, action: str):
|
||||
limit, window = RATE_LIMITS.get(action, (100, 60))
|
||||
now = time.time()
|
||||
key = f"{user_id}:{action}"
|
||||
|
||||
# Alte Requests entfernen
|
||||
request_counts[key] = [
|
||||
t for t in request_counts[key]
|
||||
if now - t < window
|
||||
]
|
||||
|
||||
if len(request_counts[key]) >= limit:
|
||||
raise HTTPException(429, {
|
||||
"error": {
|
||||
"message": f"Rate limit erreicht: {limit} Requests pro {window}s",
|
||||
"type": "rate_limit_error",
|
||||
"code": "rate_limit_exceeded"
|
||||
}
|
||||
})
|
||||
|
||||
request_counts[key].append(now)
|
||||
27
app/models.py
Normal file
27
app/models.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
class StoreCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
class StoreResponse(BaseModel):
|
||||
store_id: UUID
|
||||
name: str
|
||||
|
||||
class UpsertRequest(BaseModel):
|
||||
store_id: UUID
|
||||
texts: list[str] = Field(..., min_length=1)
|
||||
metadata: list[dict] = []
|
||||
|
||||
class QueryRequest(BaseModel):
|
||||
store_id: UUID
|
||||
query: str = Field(..., min_length=1)
|
||||
top_k: int = Field(default=5, ge=1, le=50)
|
||||
filter: Optional[dict] = None
|
||||
|
||||
class QueryResult(BaseModel):
|
||||
id: UUID
|
||||
content: str
|
||||
metadata: dict
|
||||
similarity: float
|
||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
186
app/routers/admin.py
Normal file
186
app/routers/admin.py
Normal file
@@ -0,0 +1,186 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from app.auth import verify_api_key
|
||||
from app.database import get_db
|
||||
import httpx
|
||||
import os
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
LITELLM_URL = os.getenv("LITELLM_PROXY_URL", "http://litellm:4000")
|
||||
MASTER_KEY = os.getenv("LITELLM_MASTER_KEY")
|
||||
ADMIN_IDS = os.getenv("ADMIN_USER_IDS", "").split(",")
|
||||
|
||||
|
||||
# --- Admin-Check ---
|
||||
async def require_admin(user: dict = Depends(verify_api_key)):
|
||||
if user["user_id"] not in ADMIN_IDS:
|
||||
raise HTTPException(403, "Admin-Zugriff erforderlich")
|
||||
return user
|
||||
|
||||
|
||||
# --- Stats ---
|
||||
@router.get("/stats")
|
||||
async def get_stats(
|
||||
admin=Depends(require_admin),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
stats = await db.fetchrow(
|
||||
"""SELECT
|
||||
(SELECT COUNT(*) FROM vector_stores) AS total_stores,
|
||||
(SELECT COUNT(*) FROM documents) AS total_documents,
|
||||
(SELECT COUNT(DISTINCT owner_user_id)
|
||||
FROM vector_stores) AS total_users,
|
||||
(SELECT COUNT(*) FROM store_permissions) AS total_permissions"""
|
||||
)
|
||||
return dict(stats)
|
||||
|
||||
|
||||
# --- User Endpoints ---
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
admin=Depends(require_admin),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
rows = await db.fetch(
|
||||
"""SELECT
|
||||
owner_user_id AS user_id,
|
||||
COUNT(id) AS store_count,
|
||||
MAX(created_at) AS last_activity
|
||||
FROM vector_stores
|
||||
GROUP BY owner_user_id
|
||||
ORDER BY last_activity DESC"""
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/stores")
|
||||
async def get_user_stores(
|
||||
user_id: str,
|
||||
admin=Depends(require_admin),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
rows = await db.fetch(
|
||||
"""SELECT
|
||||
vs.id,
|
||||
vs.name,
|
||||
vs.created_at,
|
||||
COUNT(d.id) AS document_count
|
||||
FROM vector_stores vs
|
||||
LEFT JOIN documents d ON d.store_id = vs.id
|
||||
WHERE vs.owner_user_id = $1
|
||||
GROUP BY vs.id, vs.name, vs.created_at""",
|
||||
user_id
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}/stores/{store_id}")
|
||||
async def admin_delete_store(
|
||||
user_id: str,
|
||||
store_id: str,
|
||||
admin=Depends(require_admin),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
deleted = await db.fetchval(
|
||||
"""DELETE FROM vector_stores
|
||||
WHERE id = $1 AND owner_user_id = $2
|
||||
RETURNING id""",
|
||||
store_id, user_id
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(404, "Store nicht gefunden")
|
||||
return {"deleted": str(deleted)}
|
||||
|
||||
|
||||
# --- Permission Endpoints ---
|
||||
@router.get("/stores/{store_id}/permissions")
|
||||
async def get_permissions(
|
||||
store_id: str,
|
||||
admin=Depends(require_admin),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
rows = await db.fetch(
|
||||
"""SELECT user_id, permission, created_at
|
||||
FROM store_permissions
|
||||
WHERE store_id = $1""",
|
||||
store_id
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/stores/{store_id}/permissions")
|
||||
async def grant_permission(
|
||||
store_id: str,
|
||||
user_id: str,
|
||||
permission: str = "read",
|
||||
admin=Depends(require_admin),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
if permission not in ("read", "write", "admin"):
|
||||
raise HTTPException(400, "Ungültige Permission: read, write oder admin")
|
||||
|
||||
await db.execute(
|
||||
"""INSERT INTO store_permissions (store_id, user_id, permission)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (store_id, user_id)
|
||||
DO UPDATE SET permission = $3""",
|
||||
store_id, user_id, permission
|
||||
)
|
||||
return {"granted": permission, "user_id": user_id}
|
||||
|
||||
|
||||
@router.delete("/stores/{store_id}/permissions/{user_id}")
|
||||
async def revoke_permission(
|
||||
store_id: str,
|
||||
user_id: str,
|
||||
admin=Depends(require_admin),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
await db.execute(
|
||||
"DELETE FROM store_permissions WHERE store_id=$1 AND user_id=$2",
|
||||
store_id, user_id
|
||||
)
|
||||
return {"revoked": user_id}
|
||||
|
||||
|
||||
# --- Key Management ---
|
||||
@router.post("/users/{user_id}/rotate-key")
|
||||
async def rotate_key(
|
||||
user_id: str,
|
||||
admin=Depends(require_admin),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{LITELLM_URL}/key/generate",
|
||||
headers={"Authorization": f"Bearer {MASTER_KEY}"},
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"key_alias": f"{user_id}-rotated"
|
||||
}
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(500, "Key-Rotation fehlgeschlagen")
|
||||
|
||||
store_count = await db.fetchval(
|
||||
"SELECT COUNT(*) FROM vector_stores WHERE owner_user_id=$1",
|
||||
user_id
|
||||
)
|
||||
return {
|
||||
"new_key": resp.json()["key"],
|
||||
"user_id": user_id,
|
||||
"stores_preserved": store_count
|
||||
}
|
||||
|
||||
@router.get("/verify")
|
||||
async def verify_admin(
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Prüft ob der API Key Admin-Rechte hat.
|
||||
Gibt 200 zurück wenn Admin, 403 wenn nicht.
|
||||
"""
|
||||
return {
|
||||
"admin": True,
|
||||
"user_id": admin["user_id"],
|
||||
}
|
||||
41
app/routers/chunking.py
Normal file
41
app/routers/chunking.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from typing import Optional
|
||||
|
||||
def chunk_text(
|
||||
text: str,
|
||||
chunk_size: int = 512,
|
||||
overlap: int = 50,
|
||||
separator: str = "\n"
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Text in ueberlappende Chunks aufteilen
|
||||
chunk_size: Max Zeichen pro Chunk
|
||||
overlap: Ueberlappung zwischen Chunks
|
||||
separator: Trennzeichen fuer saubere Splits
|
||||
"""
|
||||
# Zuerst an Separatoren splitten
|
||||
paragraphs = text.split(separator)
|
||||
chunks = []
|
||||
current = ""
|
||||
index = 0
|
||||
|
||||
for para in paragraphs:
|
||||
if len(current) + len(para) > chunk_size and current:
|
||||
chunks.append({
|
||||
"text": current.strip(),
|
||||
"index": index,
|
||||
"start": text.find(current.strip()),
|
||||
})
|
||||
# Overlap: letzten Teil behalten
|
||||
current = current[-overlap:] + para
|
||||
index += 1
|
||||
else:
|
||||
current += separator + para
|
||||
|
||||
if current.strip():
|
||||
chunks.append({
|
||||
"text": current.strip(),
|
||||
"index": index,
|
||||
"start": text.find(current.strip()),
|
||||
})
|
||||
|
||||
return chunks
|
||||
113
app/routers/documents.py
Normal file
113
app/routers/documents.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import json
|
||||
import httpx
|
||||
import os
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from app.auth import verify_api_key
|
||||
from app.database import get_db
|
||||
from app.models import UpsertRequest, QueryRequest
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LITELLM_URL = os.getenv("LITELLM_PROXY_URL", "http://litellm:4000")
|
||||
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-ada-002")
|
||||
|
||||
|
||||
async def _embed(text: str, token: str) -> list[float]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{LITELLM_URL}/embeddings",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": EMBEDDING_MODEL,
|
||||
"input": text
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"Embedding Fehler: {resp.status_code} - {resp.text}")
|
||||
raise HTTPException(
|
||||
502,
|
||||
f"Embedding fehlgeschlagen: {resp.status_code} - {resp.text}"
|
||||
)
|
||||
|
||||
return resp.json()["data"][0]["embedding"]
|
||||
|
||||
|
||||
async def _check_access(db, store_id: str, user_id: str):
|
||||
row = await db.fetchrow(
|
||||
"SELECT owner_user_id FROM vector_stores WHERE id=$1", store_id
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Store nicht gefunden")
|
||||
if row["owner_user_id"] != user_id:
|
||||
shared = await db.fetchval(
|
||||
"SELECT 1 FROM store_permissions WHERE store_id=$1 AND user_id=$2",
|
||||
store_id, user_id
|
||||
)
|
||||
if not shared:
|
||||
raise HTTPException(403, "Kein Zugriff")
|
||||
|
||||
|
||||
@router.post("/upsert")
|
||||
async def upsert(
|
||||
body: UpsertRequest,
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
await _check_access(db, str(body.store_id), user["user_id"])
|
||||
|
||||
ids = []
|
||||
for i, text in enumerate(body.texts):
|
||||
embedding = await _embed(text, user["token"])
|
||||
meta = body.metadata[i] if i < len(body.metadata) else {}
|
||||
|
||||
doc_id = await db.fetchval(
|
||||
"""INSERT INTO documents (store_id, content, metadata, embedding)
|
||||
VALUES ($1, $2, $3, $4::vector) RETURNING id""",
|
||||
str(body.store_id),
|
||||
text,
|
||||
json.dumps(meta),
|
||||
str(embedding)
|
||||
)
|
||||
ids.append(str(doc_id))
|
||||
|
||||
return {"inserted": len(ids), "ids": ids}
|
||||
|
||||
|
||||
@router.post("/query")
|
||||
async def query(
|
||||
body: QueryRequest,
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
await _check_access(db, str(body.store_id), user["user_id"])
|
||||
|
||||
q_emb = await _embed(body.query, user["token"])
|
||||
|
||||
rows = await db.fetch(
|
||||
"""SELECT id, content, metadata,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM documents
|
||||
WHERE store_id = $2
|
||||
ORDER BY embedding <=> $1::vector
|
||||
LIMIT $3""",
|
||||
str(q_emb),
|
||||
str(body.store_id),
|
||||
body.top_k
|
||||
)
|
||||
|
||||
return {"results": [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"content": r["content"],
|
||||
"metadata": r["metadata"],
|
||||
"similarity": float(r["similarity"])
|
||||
}
|
||||
for r in rows
|
||||
]}
|
||||
35
app/routers/files.py
Normal file
35
app/routers/files.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
|
||||
import pypdf
|
||||
import docx
|
||||
import io
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
async def extract_text(file: UploadFile) -> str:
|
||||
"""Text aus verschiedenen Dateiformaten extrahieren"""
|
||||
content = await file.read()
|
||||
|
||||
if file.filename.endswith(".pdf"):
|
||||
pdf = pypdf.PdfReader(io.BytesIO(content))
|
||||
return "\n".join(page.extract_text() for page in pdf.pages)
|
||||
|
||||
elif file.filename.endswith(".docx"):
|
||||
doc = docx.Document(io.BytesIO(content))
|
||||
return "\n".join(p.text for p in doc.paragraphs)
|
||||
|
||||
elif file.filename.endswith(".txt"):
|
||||
return content.decode("utf-8")
|
||||
|
||||
else:
|
||||
raise HTTPException(400, f"Nicht unterstütztes Format: {file.filename}")
|
||||
|
||||
|
||||
@router.post("/v1/vector_stores/{store_id}/upload")
|
||||
async def upload_file(
|
||||
store_id: str,
|
||||
file: UploadFile = File(...),
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
text = await extract_text(file)
|
||||
chunks = chunk_text(text)
|
||||
787
app/routers/openai_compat.py
Normal file
787
app/routers/openai_compat.py
Normal file
@@ -0,0 +1,787 @@
|
||||
import json
|
||||
import httpx
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
import pypdf
|
||||
import docx
|
||||
import io
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from app.auth import verify_api_key
|
||||
from app.database import get_db
|
||||
from app.utils.stats import track_usage
|
||||
from app.utils.chunking import chunk_text
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LITELLM_URL = os.getenv("LITELLM_PROXY_URL", "http://litellm:4000")
|
||||
LITELLM_MASTER = os.getenv("LITELLM_MASTER_KEY")
|
||||
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "cosair/multilingual-e5-large-instruct")
|
||||
|
||||
|
||||
class VectorStoreCreate(BaseModel):
|
||||
name: str
|
||||
metadata: dict = {}
|
||||
|
||||
class VectorStoreResponse(BaseModel):
|
||||
id: str
|
||||
object: str = "vector_store"
|
||||
name: str
|
||||
metadata: dict = {}
|
||||
created_at: int
|
||||
|
||||
class FileUploadRequest(BaseModel):
|
||||
texts: list[str]
|
||||
metadata: list[dict] = []
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
query: str
|
||||
top_k: int = Field(default=5, ge=1, le=50)
|
||||
rerank: bool = False
|
||||
rerank_model: Optional[str] = None
|
||||
filters: Optional[dict] = None
|
||||
|
||||
class EmbeddingRequest(BaseModel):
|
||||
input: str | list[str]
|
||||
model: Optional[str] = None
|
||||
encoding_format: Optional[str] = "float"
|
||||
|
||||
class RAGRequest(BaseModel):
|
||||
query: str
|
||||
model: str = "cosair/gemma4:31b"
|
||||
top_k: int = 5
|
||||
rerank: bool = False
|
||||
system_prompt: Optional[str] = None
|
||||
messages: list[dict] = []
|
||||
|
||||
|
||||
# Hilfsfunktionen
|
||||
|
||||
def is_embedding_model(model: dict) -> bool:
|
||||
"""Prueft ob ein Modell ein Embedding Modell ist - nur ueber mode"""
|
||||
mode = (
|
||||
model.get("mode") or
|
||||
model.get("model_info", {}).get("mode")
|
||||
)
|
||||
return mode == "embedding"
|
||||
|
||||
|
||||
async def _get_all_models() -> list[dict]:
|
||||
"""
|
||||
Alle Modelle mit Master Key holen.
|
||||
Master Key gibt korrekte mode Infos fuer alle Modelle zurueck.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{LITELLM_URL}/model_group/info",
|
||||
headers={"Authorization": f"Bearer {LITELLM_MASTER}"},
|
||||
timeout=10.0
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(503, f"LiteLLM nicht erreichbar: {e}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(502, f"Modelle konnten nicht abgerufen werden: {resp.text}")
|
||||
|
||||
models = []
|
||||
for m in resp.json().get("data", []):
|
||||
models.append({
|
||||
**m,
|
||||
"id": m.get("model_group", m.get("id", "")),
|
||||
})
|
||||
|
||||
return models
|
||||
|
||||
|
||||
async def _embed(
|
||||
text: str,
|
||||
token: str,
|
||||
model: Optional[str] = None
|
||||
) -> list[float]:
|
||||
"""Embedding ueber LiteLLM generieren"""
|
||||
use_model = model or EMBEDDING_MODEL
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{LITELLM_URL}/embeddings",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": use_model,
|
||||
"input": text
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"Embedding Fehler: {resp.status_code} - {resp.text}")
|
||||
raise HTTPException(502, f"Embedding fehlgeschlagen: {resp.text}")
|
||||
|
||||
return resp.json()["data"][0]["embedding"]
|
||||
|
||||
|
||||
async def _check_access(db, store_id: str, user_id: str):
|
||||
"""Zugriff auf Store pruefen"""
|
||||
row = await db.fetchrow(
|
||||
"SELECT owner_user_id FROM vector_stores WHERE id=$1", store_id
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, detail={
|
||||
"error": {
|
||||
"message": f"No vector store found with id '{store_id}'",
|
||||
"type": "invalid_request_error",
|
||||
"code": "not_found"
|
||||
}
|
||||
})
|
||||
if row["owner_user_id"] != user_id:
|
||||
shared = await db.fetchval(
|
||||
"SELECT 1 FROM store_permissions WHERE store_id=$1 AND user_id=$2",
|
||||
store_id, user_id
|
||||
)
|
||||
if not shared:
|
||||
raise HTTPException(403, detail={
|
||||
"error": {
|
||||
"message": "You don't have access to this vector store",
|
||||
"type": "invalid_request_error",
|
||||
"code": "permission_denied"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
async def _rerank(
|
||||
query: str,
|
||||
results: list[dict],
|
||||
model: str,
|
||||
token: str
|
||||
) -> list[dict]:
|
||||
"""Ergebnisse mit Reranker neu sortieren"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{LITELLM_URL}/rerank",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"model": model,
|
||||
"query": query,
|
||||
"documents": [r["content"][0]["text"] for r in results]
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"Rerank Fehler: {resp.text}")
|
||||
return results
|
||||
|
||||
reranked = resp.json()["results"]
|
||||
|
||||
return [
|
||||
{**results[r["index"]], "score": r["relevance_score"]}
|
||||
for r in sorted(reranked, key=lambda x: x["relevance_score"], reverse=True)
|
||||
]
|
||||
|
||||
|
||||
# Models Endpoints
|
||||
|
||||
@router.get("/models")
|
||||
async def list_models(
|
||||
user: dict = Depends(verify_api_key),
|
||||
):
|
||||
"""Alle verfuegbaren Modelle von LiteLLM"""
|
||||
models = await _get_all_models()
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": m["id"],
|
||||
"object": "model",
|
||||
"mode": m.get("mode"),
|
||||
"owned_by": "system",
|
||||
}
|
||||
for m in models
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/models/{model_id:path}")
|
||||
async def get_model(
|
||||
model_id: str,
|
||||
user: dict = Depends(verify_api_key),
|
||||
):
|
||||
"""Einzelnes Modell von LiteLLM"""
|
||||
all_models = await _get_all_models()
|
||||
model_lookup = {m["id"]: m for m in all_models}
|
||||
|
||||
if model_id not in model_lookup:
|
||||
raise HTTPException(404, {
|
||||
"error": {
|
||||
"message": f"Modell '{model_id}' nicht gefunden",
|
||||
"type": "invalid_request_error",
|
||||
"code": "not_found"
|
||||
}
|
||||
})
|
||||
|
||||
m = model_lookup[model_id]
|
||||
return {
|
||||
"id": m["id"],
|
||||
"object": "model",
|
||||
"mode": m.get("mode"),
|
||||
"owned_by": "system",
|
||||
}
|
||||
|
||||
|
||||
# Embedding Endpoints
|
||||
|
||||
@router.get("/embeddings/models")
|
||||
async def list_embedding_models(
|
||||
user: dict = Depends(verify_api_key),
|
||||
):
|
||||
"""Nur Embedding Modelle - gefiltert über mode mit Master Key"""
|
||||
all_models = await _get_all_models()
|
||||
|
||||
embedding_models = [
|
||||
{
|
||||
"id": m["id"],
|
||||
"object": "model",
|
||||
"owned_by": "system",
|
||||
"default": m["id"] == EMBEDDING_MODEL,
|
||||
}
|
||||
for m in all_models
|
||||
if is_embedding_model(m)
|
||||
]
|
||||
|
||||
return {
|
||||
"object": "list",
|
||||
"default": EMBEDDING_MODEL,
|
||||
"data": embedding_models
|
||||
}
|
||||
|
||||
|
||||
@router.post("/embeddings")
|
||||
async def create_embeddings(
|
||||
body: EmbeddingRequest,
|
||||
user: dict = Depends(verify_api_key),
|
||||
):
|
||||
"""Embeddings erstellen - einzeln oder als Liste"""
|
||||
start = time.time()
|
||||
model = body.model or EMBEDDING_MODEL
|
||||
inputs = body.input if isinstance(body.input, list) else [body.input]
|
||||
|
||||
all_models = await _get_all_models()
|
||||
model_lookup = {m["id"]: m for m in all_models}
|
||||
|
||||
if model in model_lookup and not is_embedding_model(model_lookup[model]):
|
||||
raise HTTPException(400, {
|
||||
"error": {
|
||||
"message": f"'{model}' ist kein Embedding Modell",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_model"
|
||||
}
|
||||
})
|
||||
|
||||
embeddings = []
|
||||
total_tokens = 0
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for i, text in enumerate(inputs):
|
||||
resp = await client.post(
|
||||
f"{LITELLM_URL}/embeddings",
|
||||
headers={
|
||||
"Authorization": f"Bearer {user['token']}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={"model": model, "input": text},
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"Embedding Fehler: {resp.status_code} - {resp.text}")
|
||||
raise HTTPException(502, f"Embedding fehlgeschlagen: {resp.text}")
|
||||
|
||||
data = resp.json()
|
||||
total_tokens += data.get("usage", {}).get("total_tokens", 0)
|
||||
embeddings.append({
|
||||
"object": "embedding",
|
||||
"index": i,
|
||||
"embedding": data["data"][0]["embedding"]
|
||||
})
|
||||
|
||||
await track_usage(
|
||||
user_id=user["user_id"],
|
||||
action="embed",
|
||||
tokens=total_tokens,
|
||||
duration=time.time() - start
|
||||
)
|
||||
|
||||
return {
|
||||
"object": "list",
|
||||
"model": model,
|
||||
"data": embeddings,
|
||||
"usage": {
|
||||
"prompt_tokens": total_tokens,
|
||||
"total_tokens": total_tokens
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Vector Store Endpoints
|
||||
|
||||
@router.post("/vector_stores", response_model=VectorStoreResponse)
|
||||
async def create_vector_store(
|
||||
body: VectorStoreCreate,
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Neuen Vector Store anlegen"""
|
||||
row = await db.fetchrow(
|
||||
"""INSERT INTO vector_stores (name, owner_user_id)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, name, created_at""",
|
||||
body.name, user["user_id"]
|
||||
)
|
||||
return VectorStoreResponse(
|
||||
id=str(row["id"]),
|
||||
name=row["name"],
|
||||
metadata=body.metadata,
|
||||
created_at=int(row["created_at"].timestamp())
|
||||
)
|
||||
|
||||
|
||||
@router.get("/vector_stores")
|
||||
async def list_vector_stores(
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Alle eigenen Vector Stores auflisten"""
|
||||
rows = await db.fetch(
|
||||
"""SELECT vs.id, vs.name, vs.created_at,
|
||||
COUNT(d.id) AS file_counts
|
||||
FROM vector_stores vs
|
||||
LEFT JOIN documents d ON d.store_id = vs.id
|
||||
WHERE vs.owner_user_id = $1
|
||||
GROUP BY vs.id, vs.name, vs.created_at
|
||||
ORDER BY vs.created_at DESC""",
|
||||
user["user_id"]
|
||||
)
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"object": "vector_store",
|
||||
"name": r["name"],
|
||||
"created_at": int(r["created_at"].timestamp()),
|
||||
"file_counts": {"total": r["file_counts"]}
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/vector_stores/{store_id}")
|
||||
async def get_vector_store(
|
||||
store_id: str,
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Einzelnen Vector Store abrufen"""
|
||||
await _check_access(db, store_id, user["user_id"])
|
||||
|
||||
row = await db.fetchrow(
|
||||
"""SELECT vs.id, vs.name, vs.created_at,
|
||||
COUNT(d.id) AS file_counts
|
||||
FROM vector_stores vs
|
||||
LEFT JOIN documents d ON d.store_id = vs.id
|
||||
WHERE vs.id = $1
|
||||
GROUP BY vs.id, vs.name, vs.created_at""",
|
||||
store_id
|
||||
)
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"object": "vector_store",
|
||||
"name": row["name"],
|
||||
"created_at": int(row["created_at"].timestamp()),
|
||||
"file_counts": {"total": row["file_counts"]}
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/vector_stores/{store_id}")
|
||||
async def delete_vector_store(
|
||||
store_id: str,
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Vector Store loeschen"""
|
||||
deleted = await db.fetchval(
|
||||
"""DELETE FROM vector_stores
|
||||
WHERE id=$1 AND owner_user_id=$2
|
||||
RETURNING id""",
|
||||
store_id, user["user_id"]
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(404, "Vector store nicht gefunden")
|
||||
return {
|
||||
"id": store_id,
|
||||
"object": "vector_store.deleted",
|
||||
"deleted": True
|
||||
}
|
||||
|
||||
|
||||
# Files Endpoints
|
||||
|
||||
@router.post("/vector_stores/{store_id}/files")
|
||||
async def add_files(
|
||||
store_id: str,
|
||||
body: FileUploadRequest,
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Dokumente in Vector Store einfuegen"""
|
||||
start = time.time()
|
||||
await _check_access(db, store_id, user["user_id"])
|
||||
|
||||
ids = []
|
||||
for i, text in enumerate(body.texts):
|
||||
embedding = await _embed(text, user["token"])
|
||||
meta = body.metadata[i] if i < len(body.metadata) else {}
|
||||
|
||||
doc_id = await db.fetchval(
|
||||
"""INSERT INTO documents (store_id, content, metadata, embedding)
|
||||
VALUES ($1, $2, $3, $4::vector) RETURNING id""",
|
||||
store_id, text, json.dumps(meta), str(embedding)
|
||||
)
|
||||
ids.append(str(doc_id))
|
||||
|
||||
await track_usage(
|
||||
user_id=user["user_id"],
|
||||
action="upsert",
|
||||
store_id=store_id,
|
||||
duration=time.time() - start
|
||||
)
|
||||
|
||||
return {
|
||||
"object": "vector_store.file_batch",
|
||||
"counts": {
|
||||
"completed": len(ids),
|
||||
"failed": 0,
|
||||
"total": len(body.texts)
|
||||
},
|
||||
"ids": ids
|
||||
}
|
||||
|
||||
|
||||
@router.get("/vector_stores/{store_id}/files")
|
||||
async def list_files(
|
||||
store_id: str,
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Alle Dokumente eines Vector Stores auflisten"""
|
||||
await _check_access(db, store_id, user["user_id"])
|
||||
|
||||
rows = await db.fetch(
|
||||
"""SELECT id, content, metadata, created_at
|
||||
FROM documents
|
||||
WHERE store_id=$1
|
||||
ORDER BY created_at DESC""",
|
||||
store_id
|
||||
)
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"object": "vector_store.file",
|
||||
"content": r["content"][:100] + "...",
|
||||
"metadata": r["metadata"],
|
||||
"created_at": int(r["created_at"].timestamp())
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/vector_stores/{store_id}/files/{file_id}")
|
||||
async def delete_file(
|
||||
store_id: str,
|
||||
file_id: str,
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Einzelnes Dokument loeschen"""
|
||||
await _check_access(db, store_id, user["user_id"])
|
||||
|
||||
deleted = await db.fetchval(
|
||||
"DELETE FROM documents WHERE id=$1 AND store_id=$2 RETURNING id",
|
||||
file_id, store_id
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(404, "File nicht gefunden")
|
||||
return {
|
||||
"id": file_id,
|
||||
"object": "vector_store.file.deleted",
|
||||
"deleted": True
|
||||
}
|
||||
|
||||
|
||||
# Search Endpoint
|
||||
|
||||
@router.post("/vector_stores/{store_id}/search")
|
||||
async def search(
|
||||
store_id: str,
|
||||
body: SearchRequest,
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Aehnliche Dokumente im Vector Store suchen"""
|
||||
start = time.time()
|
||||
await _check_access(db, store_id, user["user_id"])
|
||||
|
||||
q_emb = await _embed(body.query, user["token"])
|
||||
fetch_k = body.top_k * 3 if body.rerank else body.top_k
|
||||
|
||||
rows = await db.fetch(
|
||||
"""SELECT id, content, metadata,
|
||||
1 - (embedding <=> $1::vector) AS score
|
||||
FROM documents
|
||||
WHERE store_id = $2
|
||||
ORDER BY embedding <=> $1::vector
|
||||
LIMIT $3""",
|
||||
str(q_emb), store_id, fetch_k
|
||||
)
|
||||
|
||||
results = []
|
||||
for r in rows:
|
||||
metadata = r["metadata"]
|
||||
if isinstance(metadata, str):
|
||||
try:
|
||||
metadata = json.loads(metadata)
|
||||
except Exception:
|
||||
metadata = {}
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
|
||||
results.append({
|
||||
"id": str(r["id"]),
|
||||
"object": "vector_store.search_result",
|
||||
"score": float(r["score"]),
|
||||
"content": [{"type": "text", "text": r["content"]}],
|
||||
"metadata": metadata
|
||||
})
|
||||
|
||||
if body.rerank:
|
||||
rerank_model = body.rerank_model or "cosair/bge-reranker-v2-m3"
|
||||
results = await _rerank(body.query, results, rerank_model, user["token"])
|
||||
results = results[:body.top_k]
|
||||
|
||||
await track_usage(
|
||||
user_id=user["user_id"],
|
||||
action="search",
|
||||
store_id=store_id,
|
||||
duration=time.time() - start
|
||||
)
|
||||
|
||||
return {"object": "list", "data": results}
|
||||
|
||||
|
||||
# RAG Endpoint
|
||||
|
||||
@router.post("/vector_stores/{store_id}/rag")
|
||||
async def rag(
|
||||
store_id: str,
|
||||
body: RAGRequest,
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Retrieval Augmented Generation"""
|
||||
start = time.time()
|
||||
await _check_access(db, store_id, user["user_id"])
|
||||
|
||||
q_emb = await _embed(body.query, user["token"])
|
||||
fetch_k = body.top_k * 3 if body.rerank else body.top_k
|
||||
|
||||
rows = await db.fetch(
|
||||
"""SELECT id, content, metadata,
|
||||
1 - (embedding <=> $1::vector) AS score
|
||||
FROM documents
|
||||
WHERE store_id = $2
|
||||
ORDER BY embedding <=> $1::vector
|
||||
LIMIT $3""",
|
||||
str(q_emb), store_id, fetch_k
|
||||
)
|
||||
|
||||
results = [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"content": r["content"],
|
||||
"score": float(r["score"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
if body.rerank:
|
||||
results = await _rerank(
|
||||
body.query, results,
|
||||
"cosair/bge-reranker-v2-m3",
|
||||
user["token"]
|
||||
)
|
||||
results = results[:body.top_k]
|
||||
|
||||
context = "\n\n".join([
|
||||
f"[{i+1}] {r['content']}"
|
||||
for i, r in enumerate(results)
|
||||
])
|
||||
|
||||
system_prompt = body.system_prompt or (
|
||||
"Du bist ein hilfreicher Assistent. "
|
||||
"Beantworte Fragen ausschließlich basierend auf dem gegebenen Kontext. "
|
||||
"Wenn die Antwort nicht im Kontext zu finden ist, sage das ehrlich.\n\n"
|
||||
f"Kontext:\n{context}"
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
*body.messages,
|
||||
{"role": "user", "content": body.query}
|
||||
]
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{LITELLM_URL}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {user['token']}"},
|
||||
json={"model": body.model, "messages": messages},
|
||||
timeout=60.0
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(502, f"LLM Fehler: {resp.text}")
|
||||
|
||||
llm_data = resp.json()
|
||||
answer = llm_data["choices"][0]["message"]["content"]
|
||||
total_tokens = llm_data.get("usage", {}).get("total_tokens", 0)
|
||||
|
||||
await track_usage(
|
||||
user_id=user["user_id"],
|
||||
action="rag",
|
||||
store_id=store_id,
|
||||
tokens=total_tokens,
|
||||
duration=time.time() - start
|
||||
)
|
||||
|
||||
return {
|
||||
"object": "rag.response",
|
||||
"answer": answer,
|
||||
"sources": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"content": r["content"][:200] + "...",
|
||||
"score": r["score"]
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
"model": body.model,
|
||||
"usage": llm_data.get("usage", {})
|
||||
}
|
||||
|
||||
@router.post("/vector_stores/{store_id}/upload")
|
||||
async def upload_file(
|
||||
store_id: str,
|
||||
file: UploadFile = File(...),
|
||||
chunk_size: int = Form(default=512),
|
||||
chunk_overlap: int = Form(default=50),
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Datei hochladen, chunken und in Vector Store speichern"""
|
||||
start = time.time()
|
||||
await _check_access(db, store_id, user["user_id"])
|
||||
|
||||
content = await file.read()
|
||||
filename = file.filename.lower()
|
||||
|
||||
try:
|
||||
if filename.endswith(".pdf"):
|
||||
pdf = pypdf.PdfReader(io.BytesIO(content))
|
||||
text = "\n".join(
|
||||
page.extract_text()
|
||||
for page in pdf.pages
|
||||
if page.extract_text()
|
||||
)
|
||||
|
||||
elif filename.endswith(".docx"):
|
||||
doc = docx.Document(io.BytesIO(content))
|
||||
text = "\n".join(
|
||||
p.text for p in doc.paragraphs if p.text.strip()
|
||||
)
|
||||
|
||||
elif filename.endswith(".txt"):
|
||||
text = content.decode("utf-8")
|
||||
|
||||
elif filename.endswith(".md"):
|
||||
text = content.decode("utf-8")
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Nicht unterstütztes Format: {file.filename}. "
|
||||
f"Unterstützt: .pdf, .docx, .txt, .md"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(422, f"Datei konnte nicht gelesen werden: {e}")
|
||||
|
||||
if not text.strip():
|
||||
raise HTTPException(422, "Datei enthaelt keinen Text")
|
||||
|
||||
chunks = chunk_text(
|
||||
text=text,
|
||||
chunk_size=chunk_size,
|
||||
overlap=chunk_overlap
|
||||
)
|
||||
|
||||
ids = []
|
||||
failed = 0
|
||||
|
||||
for chunk in chunks:
|
||||
try:
|
||||
embedding = await _embed(chunk["text"], user["token"])
|
||||
doc_id = await db.fetchval(
|
||||
"""INSERT INTO documents (store_id, content, metadata, embedding)
|
||||
VALUES ($1, $2, $3, $4::vector) RETURNING id""",
|
||||
store_id,
|
||||
chunk["text"],
|
||||
json.dumps({
|
||||
"source": file.filename,
|
||||
"chunk": chunk["index"],
|
||||
"start": chunk.get("start", 0),
|
||||
}),
|
||||
str(embedding)
|
||||
)
|
||||
ids.append(str(doc_id))
|
||||
except Exception as e:
|
||||
logger.error(f"Chunk {chunk['index']} fehlgeschlagen: {e}")
|
||||
failed += 1
|
||||
|
||||
await track_usage(
|
||||
user_id=user["user_id"],
|
||||
action="upload",
|
||||
store_id=store_id,
|
||||
duration=time.time() - start
|
||||
)
|
||||
|
||||
return {
|
||||
"object": "vector_store.file_batch",
|
||||
"filename": file.filename,
|
||||
"counts": {
|
||||
"completed": len(ids),
|
||||
"failed": failed,
|
||||
"total": len(chunks)
|
||||
},
|
||||
"ids": ids
|
||||
}
|
||||
45
app/routers/stores.py
Normal file
45
app/routers/stores.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from app.auth import verify_api_key
|
||||
from app.database import get_db
|
||||
from app.models import StoreCreate, StoreResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("", response_model=StoreResponse)
|
||||
async def create_store(
|
||||
body: StoreCreate,
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
store_id = await db.fetchval(
|
||||
"INSERT INTO vector_stores (name, owner_user_id) VALUES ($1,$2) RETURNING id",
|
||||
body.name, user["user_id"]
|
||||
)
|
||||
return StoreResponse(store_id=store_id, name=body.name)
|
||||
|
||||
@router.get("")
|
||||
async def list_stores(
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
rows = await db.fetch(
|
||||
"SELECT id, name, created_at FROM vector_stores WHERE owner_user_id=$1",
|
||||
user["user_id"]
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@router.delete("/{store_id}")
|
||||
async def delete_store(
|
||||
store_id: str,
|
||||
user: dict = Depends(verify_api_key),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
deleted = await db.fetchval(
|
||||
"""DELETE FROM vector_stores
|
||||
WHERE id=$1 AND owner_user_id=$2
|
||||
RETURNING id""",
|
||||
store_id, user["user_id"]
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(404, "Store not found or access denied")
|
||||
return {"deleted": str(deleted)}
|
||||
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
36
app/utils/chunking.py
Normal file
36
app/utils/chunking.py
Normal file
@@ -0,0 +1,36 @@
|
||||
def chunk_text(
|
||||
text: str,
|
||||
chunk_size: int = 512,
|
||||
overlap: int = 50,
|
||||
) -> list[dict]:
|
||||
"""Text in ueberlappende Chunks aufteilen"""
|
||||
chunks = []
|
||||
start = 0
|
||||
index = 0
|
||||
|
||||
while start < len(text):
|
||||
end = start + chunk_size
|
||||
chunk = text[start:end]
|
||||
|
||||
if end < len(text):
|
||||
last_period = max(
|
||||
chunk.rfind(". "),
|
||||
chunk.rfind(".\n"),
|
||||
chunk.rfind("! "),
|
||||
chunk.rfind("? "),
|
||||
)
|
||||
if last_period > chunk_size // 2:
|
||||
end = start + last_period + 1
|
||||
chunk = text[start:end]
|
||||
|
||||
if chunk.strip():
|
||||
chunks.append({
|
||||
"text": chunk.strip(),
|
||||
"index": index,
|
||||
"start": start,
|
||||
})
|
||||
|
||||
start = end - overlap
|
||||
index += 1
|
||||
|
||||
return chunks
|
||||
24
app/utils/stats.py
Normal file
24
app/utils/stats.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional
|
||||
from app.database import pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def track_usage(
|
||||
user_id: str,
|
||||
action: str,
|
||||
store_id: Optional[str] = None,
|
||||
tokens: int = 0,
|
||||
duration: float = 0
|
||||
):
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""INSERT INTO usage_stats
|
||||
(user_id, store_id, action, tokens, duration)
|
||||
VALUES ($1, $2, $3, $4, $5)""",
|
||||
user_id, store_id, action, tokens, round(duration, 3)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Tracking Fehler: {e}")
|
||||
52
docker-compose.yml
Normal file
52
docker-compose.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
services:
|
||||
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: vectordb
|
||||
POSTGRES_USER: vecuser
|
||||
POSTGRES_PASSWORD: vecpass
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U vecuser -d vectordb"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
litellm:
|
||||
image: ghcr.io/berriai/litellm:main-latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
- ./litellm_config.yaml:/app/config.yaml
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
vector-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: runtime
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file: .env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
litellm:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c",
|
||||
"import httpx; httpx.get('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
32
k8s/admin-ui/deployment.yaml
Normal file
32
k8s/admin-ui/deployment.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: admin-ui
|
||||
namespace: vector-store
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: admin-ui
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: admin-ui
|
||||
spec:
|
||||
containers:
|
||||
- name: admin-ui
|
||||
image: harbor.cosair.de/litellm/vector-store-admin
|
||||
ports:
|
||||
- containerPort: 80
|
||||
env:
|
||||
- name: API_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: vector-store-config
|
||||
key: API_URL
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
11
k8s/admin-ui/service.yaml
Normal file
11
k8s/admin-ui/service.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: admin-ui
|
||||
namespace: vector-store
|
||||
spec:
|
||||
selector:
|
||||
app: admin-ui
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
10
k8s/configmap.yaml
Normal file
10
k8s/configmap.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: vector-store-config
|
||||
namespace: vector-store
|
||||
data:
|
||||
LITELLM_PROXY_URL: "https://llmproxy.cosiar.de"
|
||||
ADMIN_USER_IDS: "default_user_id"
|
||||
API_URL: "https://api.vector.cosair.de"
|
||||
EMBEDDING_MODEL: "cosair/multilingual-e5-large-instruct"
|
||||
33
k8s/ingress-api.yaml
Normal file
33
k8s/ingress-api.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: vector-api-ingress
|
||||
namespace: vector-store
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: "nginx"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/enable-cors: "true"
|
||||
nginx.ingress.kubernetes.io/cors-allow-origin: "https://admin.vector.cosair.de"
|
||||
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, DELETE, OPTIONS"
|
||||
nginx.ingress.kubernetes.io/cors-allow-headers: "Authorization, Content-Type"
|
||||
nginx.ingress.kubernetes.io/cors-allow-credentials: "true"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "256m"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||
nginx.ingress.kubernetes.io/proxy-connect-timeout: "600"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- api.vector.cosair.de
|
||||
secretName: vector-api-tls
|
||||
rules:
|
||||
- host: api.vector.cosair.de
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: vector-api
|
||||
port:
|
||||
number: 8000
|
||||
24
k8s/ingress-ui.yaml
Normal file
24
k8s/ingress-ui.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: admin-ui-ingress
|
||||
namespace: vector-store
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: "nginx"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- admin.vector.cosair.de
|
||||
secretName: admin-ui-tls
|
||||
rules:
|
||||
- host: admin.vector.cosair.de
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: admin-ui
|
||||
port:
|
||||
number: 80
|
||||
4
k8s/namespace.yaml
Normal file
4
k8s/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: vector-store
|
||||
9
k8s/secrets.yaml
Normal file
9
k8s/secrets.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: vector-api-secrets
|
||||
namespace: vector-store
|
||||
type: Opaque
|
||||
stringData:
|
||||
LITELLM_MASTER_KEY: "sk-master-key"
|
||||
DATABASE_URL: "postgresql://vecuser:vecpass@postgres:5432/vectordb"
|
||||
64
k8s/vector-api/deployment.yaml
Normal file
64
k8s/vector-api/deployment.yaml
Normal file
@@ -0,0 +1,64 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: vector-api
|
||||
namespace: vector-store
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: vector-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: vector-api
|
||||
spec:
|
||||
containers:
|
||||
- name: vector-api
|
||||
image: your-registry/vector-store-api:1.0.0
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: vector-api-secrets
|
||||
key: DATABASE_URL
|
||||
|
||||
- name: LITELLM_MASTER_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: vector-api-secrets
|
||||
key: LITELLM_MASTER_KEY
|
||||
|
||||
- name: LITELLM_PROXY_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: vector-store-config
|
||||
key: LITELLM_PROXY_URL
|
||||
|
||||
- name: ADMIN_USER_IDS
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: vector-store-config
|
||||
key: ADMIN_USER_IDS
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
11
k8s/vector-api/service.yaml
Normal file
11
k8s/vector-api/service.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: vector-api
|
||||
namespace: vector-store
|
||||
spec:
|
||||
selector:
|
||||
app: vector-api
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
asyncpg==0.29.0
|
||||
httpx==0.27.0
|
||||
pydantic==2.7.0
|
||||
pydantic-settings==2.2.1
|
||||
python-dotenv==1.0.1
|
||||
pgvector==0.3.0
|
||||
tenacity==8.3.0
|
||||
pypdf==4.2.0
|
||||
python-docx==1.1.0
|
||||
48
scripts/init.sql
Normal file
48
scripts/init.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vector_stores (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
owner_user_id VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
store_id UUID REFERENCES vector_stores(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
embedding vector(1024),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS store_permissions (
|
||||
store_id UUID REFERENCES vector_stores(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
permission VARCHAR(50) DEFAULT 'read',
|
||||
PRIMARY KEY (store_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_stats (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
store_id UUID REFERENCES vector_stores(id) ON DELETE SET NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
tokens INT DEFAULT 0,
|
||||
duration FLOAT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_store
|
||||
ON documents(store_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_embedding
|
||||
ON documents USING ivfflat (embedding vector_cosine_ops)
|
||||
WITH (lists = 100);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_user
|
||||
ON usage_stats(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_created
|
||||
ON usage_stats(created_at);
|
||||
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO vecuser;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO vecuser;
|
||||
21
ui/Dockerfile
Normal file
21
ui/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /build
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY index.html .
|
||||
COPY vite.config.ts .
|
||||
COPY tsconfig.json .
|
||||
COPY tailwind.config.js .
|
||||
COPY postcss.config.js .
|
||||
COPY src/ ./src/
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
RUN npm install -g serve
|
||||
COPY --from=builder /build/dist /app
|
||||
|
||||
COPY docker-entrypoint.sh /
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
11
ui/docker-entrypoint.sh
Normal file
11
ui/docker-entrypoint.sh
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
|
||||
cat > /app/config.js << EOF
|
||||
window.__RUNTIME_CONFIG__ = {
|
||||
API_URL: "${API_URL:-http://localhost:8000}"
|
||||
};
|
||||
EOF
|
||||
|
||||
echo "✅ Config generiert: API_URL=${API_URL}"
|
||||
|
||||
exec serve -s /app -l 80
|
||||
13
ui/index.html
Normal file
13
ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vector Store Admin</title>
|
||||
<script src="/config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
ui/package.json
Normal file
28
ui/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "vector-store-admin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"@tanstack/react-query": "^5.28.0",
|
||||
"axios": "^1.6.7",
|
||||
"lucide-react": "^0.368.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
}
|
||||
6
ui/postcss.config.js
Normal file
6
ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
35
ui/src/App.tsx
Normal file
35
ui/src/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Users from "./pages/Users";
|
||||
import Stores from "./pages/Stores";
|
||||
import Layout from "./components/Layout";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = localStorage.getItem("admin_token");
|
||||
return token ? <>{children}</> : <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={
|
||||
<PrivateRoute>
|
||||
<Layout />
|
||||
</PrivateRoute>
|
||||
}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="stores" element={<Stores />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
46
ui/src/api/client.ts
Normal file
46
ui/src/api/client.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import axios from "axios";
|
||||
|
||||
// Laufzeit-Config hat Vorrang vor Build-Zeit
|
||||
declare global {
|
||||
interface Window {
|
||||
__RUNTIME_CONFIG__?: {
|
||||
API_URL: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const API_URL =
|
||||
window.__RUNTIME_CONFIG__?.API_URL ?? // ← Laufzeit (K8s ConfigMap)
|
||||
import.meta.env.VITE_API_URL ?? // ← Build-Zeit (Fallback)
|
||||
"http://localhost:8000"; // ← Dev Fallback
|
||||
|
||||
const api = axios.create({ baseURL: API_URL });
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("admin_token");
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
export const adminApi = {
|
||||
getStats: () =>
|
||||
api.get("/admin/stats").then((r) => r.data),
|
||||
getUsers: () =>
|
||||
api.get("/admin/users").then((r) => r.data),
|
||||
getUserStores: (userId: string) =>
|
||||
api.get(`/admin/users/${userId}/stores`).then((r) => r.data),
|
||||
deleteStore: (userId: string, storeId: string) =>
|
||||
api.delete(`/admin/users/${userId}/stores/${storeId}`),
|
||||
rotateKey: (userId: string) =>
|
||||
api.post(`/admin/users/${userId}/rotate-key`).then((r) => r.data),
|
||||
getPermissions: (storeId: string) =>
|
||||
api.get(`/admin/stores/${storeId}/permissions`).then((r) => r.data),
|
||||
grantPermission: (storeId: string, userId: string, permission: string) =>
|
||||
api.post(`/admin/stores/${storeId}/permissions`, null, {
|
||||
params: { user_id: userId, permission },
|
||||
}),
|
||||
revokePermission: (storeId: string, userId: string) =>
|
||||
api.delete(`/admin/stores/${storeId}/permissions/${userId}`),
|
||||
};
|
||||
|
||||
export default api;
|
||||
61
ui/src/components/Layout.tsx
Normal file
61
ui/src/components/Layout.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Outlet, NavLink, useNavigate } from "react-router-dom";
|
||||
import { LayoutDashboard, Users, Database, LogOut } from "lucide-react";
|
||||
|
||||
export default function Layout() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("admin_token");
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ to: "/users", label: "Benutzer", icon: Users },
|
||||
{ to: "/stores", label: "Stores", icon: Database },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-100">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 bg-white shadow-md flex flex-col">
|
||||
<div className="p-5 border-b">
|
||||
<h1 className="font-bold text-lg">🗄️ Vector Admin</h1>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{navItems.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition
|
||||
${isActive
|
||||
? "bg-blue-50 text-blue-700 font-medium"
|
||||
: "text-gray-600 hover:bg-gray-50"}`
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-3 px-6 py-4 text-sm
|
||||
text-gray-500 hover:text-red-600 border-t transition"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Abmelden
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
ui/src/components/PermissionModal.tsx
Normal file
152
ui/src/components/PermissionModal.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { adminApi } from "../api/client";
|
||||
import { useState } from "react";
|
||||
import { X, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PermissionModal({ userId, onClose }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [newUser, setNewUser] = useState("");
|
||||
const [newPerm, setNewPerm] = useState("read");
|
||||
const [activeStore, setActiveStore] = useState<string | null>(null);
|
||||
|
||||
const { data: stores = [] } = useQuery({
|
||||
queryKey: ["user-stores", userId],
|
||||
queryFn: () => adminApi.getUserStores(userId),
|
||||
});
|
||||
|
||||
const { data: perms = [] } = useQuery({
|
||||
queryKey: ["perms", activeStore],
|
||||
queryFn: () => adminApi.getPermissions(activeStore!),
|
||||
enabled: !!activeStore,
|
||||
});
|
||||
|
||||
const grantMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
adminApi.grantPermission(activeStore!, newUser, newPerm),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["perms", activeStore] });
|
||||
setNewUser("");
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (uid: string) =>
|
||||
adminApi.revokePermission(activeStore!, uid),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: ["perms", activeStore] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b">
|
||||
<h2 className="font-bold text-lg">
|
||||
Berechtigungen — {userId}
|
||||
</h2>
|
||||
<button onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 grid grid-cols-2 gap-4">
|
||||
|
||||
{/* Stores-Liste */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2 text-sm text-gray-600">
|
||||
STORES
|
||||
</h3>
|
||||
{stores.map((s: any) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setActiveStore(s.id)}
|
||||
className={`w-full text-left p-3 rounded-lg mb-1 text-sm border transition
|
||||
${activeStore === s.id
|
||||
? "bg-blue-50 border-blue-400"
|
||||
: "hover:bg-gray-50 border-transparent"}`}
|
||||
>
|
||||
<p className="font-medium">{s.name}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{s.document_count} Dokumente
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2 text-sm text-gray-600">
|
||||
ZUGRIFF GEWÄHREN
|
||||
</h3>
|
||||
|
||||
{!activeStore ? (
|
||||
<p className="text-sm text-gray-400">Store auswählen →</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Bestehende Berechtigungen */}
|
||||
<div className="mb-3 space-y-1">
|
||||
{perms.map((p: any) => (
|
||||
<div
|
||||
key={p.user_id}
|
||||
className="flex items-center justify-between bg-gray-50 p-2 rounded text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs">{p.user_id}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium
|
||||
${p.permission === "admin" ? "bg-red-100 text-red-700" : ""}
|
||||
${p.permission === "write" ? "bg-yellow-100 text-yellow-700" : ""}
|
||||
${p.permission === "read" ? "bg-green-100 text-green-700" : ""}`}>
|
||||
{p.permission}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => revokeMutation.mutate(p.user_id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Neue Berechtigung */}
|
||||
<div className="border-t pt-3">
|
||||
<input
|
||||
placeholder="user_id"
|
||||
value={newUser}
|
||||
onChange={(e) => setNewUser(e.target.value)}
|
||||
className="w-full border rounded p-2 text-sm mb-2"
|
||||
/>
|
||||
<select
|
||||
value={newPerm}
|
||||
onChange={(e) => setNewPerm(e.target.value)}
|
||||
className="w-full border rounded p-2 text-sm mb-2"
|
||||
>
|
||||
<option value="read">read</option>
|
||||
<option value="write">write</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => grantMutation.mutate()}
|
||||
disabled={!newUser}
|
||||
className="w-full bg-blue-600 text-white py-2 rounded text-sm
|
||||
hover:bg-blue-700 disabled:opacity-50 flex items-center
|
||||
justify-center gap-1"
|
||||
>
|
||||
<Plus size={14} /> Zugriff gewähren
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
ui/src/index.css
Normal file
3
ui/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
10
ui/src/main.tsx
Normal file
10
ui/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
39
ui/src/pages/Dashboard.tsx
Normal file
39
ui/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { adminApi } from "../api/client";
|
||||
import { Database, Users, FileText, Key } from "lucide-react";
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
queryKey: ["stats"],
|
||||
queryFn: adminApi.getStats,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="p-6">Lade...</p>;
|
||||
|
||||
const cards = [
|
||||
{ label: "Gesamt Stores", value: stats?.total_stores, icon: Database, color: "blue" },
|
||||
{ label: "Gesamt User", value: stats?.total_users, icon: Users, color: "green" },
|
||||
{ label: "Gesamt Dokumente", value: stats?.total_documents, icon: FileText, color: "purple" },
|
||||
{ label: "Berechtigungen", value: stats?.total_permissions, icon: Key, color: "orange" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
{cards.map(({ label, value, icon: Icon, color }) => (
|
||||
<div key={label} className="bg-white rounded-xl shadow p-5 flex items-center gap-4">
|
||||
<div className={`p-3 rounded-full bg-${color}-100`}>
|
||||
<Icon className={`text-${color}-600`} size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{label}</p>
|
||||
<p className="text-2xl font-bold">{value ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
ui/src/pages/Login.tsx
Normal file
117
ui/src/pages/Login.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function Login() {
|
||||
const [key, setKey] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!key.trim()) {
|
||||
setError("Bitte API Key eingeben");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/admin/verify", {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
localStorage.setItem("admin_token", key);
|
||||
navigate("/");
|
||||
|
||||
} else if (resp.status === 401) {
|
||||
setError("Ungültiger API Key");
|
||||
|
||||
} else if (resp.status === 403) {
|
||||
setError("Keine Admin-Berechtigung für diesen Key");
|
||||
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
setError(data?.detail || `Fehler ${resp.status}: Anmeldung fehlgeschlagen`);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
setError(`Verbindungsfehler: ${e}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="bg-white p-8 rounded-xl shadow-md w-96">
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-5xl mb-4">🗄️</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
Vector Store Admin
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Bitte mit Admin API Key anmelden
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
setKey(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={(e) => e.key === "Enter" && !loading && handleLogin()}
|
||||
disabled={loading}
|
||||
className="w-full border rounded-lg p-3
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
disabled:opacity-50 disabled:bg-gray-50
|
||||
transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fehler */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-300 text-red-700
|
||||
rounded-lg p-3 text-sm mb-4 flex items-start gap-2">
|
||||
<span className="mt-0.5">❌</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Button */}
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={loading || !key.trim()}
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg
|
||||
hover:bg-blue-700 transition font-medium
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4
|
||||
border-b-2 border-white" />
|
||||
Anmelden...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
🔐 Anmelden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
ui/src/pages/Stores.tsx
Normal file
235
ui/src/pages/Stores.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { adminApi } from "../api/client";
|
||||
import PermissionModal from "../components/PermissionModal";
|
||||
import { Trash2, Shield, ChevronDown, ChevronUp, FileText } from "lucide-react";
|
||||
|
||||
interface Store {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_user_id: string;
|
||||
document_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
user_id: string;
|
||||
store_count: number;
|
||||
last_activity: string;
|
||||
}
|
||||
|
||||
export default function Stores() {
|
||||
const qc = useQueryClient();
|
||||
const [expandedUser, setExpandedUser] = useState<string | null>(null);
|
||||
const [selectedStore, setStore] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirm] = useState<{
|
||||
storeId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
const { data: users = [], isLoading } = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: adminApi.getUsers,
|
||||
});
|
||||
|
||||
const { data: stores = [], isLoading: storesLoading } = useQuery({
|
||||
queryKey: ["user-stores", expandedUser],
|
||||
queryFn: () => adminApi.getUserStores(expandedUser!),
|
||||
enabled: !!expandedUser,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ userId, storeId }: { userId: string; storeId: string }) =>
|
||||
adminApi.deleteStore(userId, storeId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["user-stores", expandedUser] });
|
||||
qc.invalidateQueries({ queryKey: ["users"] });
|
||||
setConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
const toggleUser = (userId: string) =>
|
||||
setExpandedUser(expandedUser === userId ? null : userId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 flex items-center gap-2 text-gray-500">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600" />
|
||||
Lade Stores...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Store-Verwaltung</h1>
|
||||
|
||||
{/* Lösch-Bestätigung */}
|
||||
{confirmDelete && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 w-96">
|
||||
<h2 className="font-bold text-lg mb-2">Store löschen?</h2>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
Store{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
{confirmDelete.name}
|
||||
</span>{" "}
|
||||
wird unwiderruflich gelöscht — inkl. aller Dokumente.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setConfirm(null)}
|
||||
className="px-4 py-2 rounded-lg border text-sm hover:bg-gray-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
deleteMutation.mutate({
|
||||
userId: confirmDelete.userId,
|
||||
storeId: confirmDelete.storeId,
|
||||
})
|
||||
}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm
|
||||
hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deleteMutation.isPending ? "Lösche..." : "Löschen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User-Gruppen */}
|
||||
<div className="space-y-3">
|
||||
{users.length === 0 && (
|
||||
<p className="text-gray-400 text-sm">Keine Stores vorhanden.</p>
|
||||
)}
|
||||
|
||||
{users.map((user: User) => (
|
||||
<div
|
||||
key={user.user_id}
|
||||
className="bg-white rounded-xl shadow overflow-hidden"
|
||||
>
|
||||
{/* User-Header */}
|
||||
<button
|
||||
onClick={() => toggleUser(user.user_id)}
|
||||
className="w-full flex items-center justify-between p-4
|
||||
hover:bg-gray-50 transition text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center
|
||||
justify-center text-blue-700 font-bold text-sm">
|
||||
{user.user_id.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm font-mono">
|
||||
{user.user_id}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{user.store_count} Store
|
||||
{user.store_count !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<span className="text-xs">
|
||||
{new Date(user.last_activity).toLocaleDateString("de-DE")}
|
||||
</span>
|
||||
{expandedUser === user.user_id
|
||||
? <ChevronUp size={18} />
|
||||
: <ChevronDown size={18} />
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Store-Liste */}
|
||||
{expandedUser === user.user_id && (
|
||||
<div className="border-t">
|
||||
{storesLoading ? (
|
||||
<div className="p-4 flex items-center gap-2 text-gray-400 text-sm">
|
||||
<div className="animate-spin rounded-full h-4 w-4
|
||||
border-b-2 border-blue-600" />
|
||||
Lade Stores...
|
||||
</div>
|
||||
) : stores.length === 0 ? (
|
||||
<p className="p-4 text-sm text-gray-400">
|
||||
Keine Stores gefunden.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-xs text-gray-500 uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">Name</th>
|
||||
<th className="text-left px-4 py-2">Dokumente</th>
|
||||
<th className="text-left px-4 py-2">Erstellt</th>
|
||||
<th className="text-left px-4 py-2">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stores.map((store: Store) => (
|
||||
<tr
|
||||
key={store.id}
|
||||
className="border-t hover:bg-gray-50 transition"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
{store.name}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 text-gray-500">
|
||||
<FileText size={14} />
|
||||
{store.document_count}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs">
|
||||
{new Date(store.created_at).toLocaleString("de-DE")}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setStore(store.id)}
|
||||
title="Berechtigungen verwalten"
|
||||
className="p-1.5 bg-blue-100 text-blue-700
|
||||
rounded hover:bg-blue-200 transition"
|
||||
>
|
||||
<Shield size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
storeId: store.id,
|
||||
userId: user.user_id,
|
||||
name: store.name,
|
||||
})
|
||||
}
|
||||
title="Store löschen"
|
||||
className="p-1.5 bg-red-100 text-red-700
|
||||
rounded hover:bg-red-200 transition"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Permission Modal */}
|
||||
{selectedStore && (
|
||||
<PermissionModal
|
||||
userId={expandedUser!}
|
||||
onClose={() => setStore(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
ui/src/pages/Users.tsx
Normal file
92
ui/src/pages/Users.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { adminApi } from "../api/client";
|
||||
import PermissionModal from "../components/PermissionModal";
|
||||
import { RefreshCw, Trash2, Shield } from "lucide-react";
|
||||
|
||||
export default function Users() {
|
||||
const qc = useQueryClient();
|
||||
const [selectedStore, setStore] = useState<string | null>(null);
|
||||
const [newKey, setNewKey] = useState<string | null>(null);
|
||||
|
||||
const { data: users = [], isLoading } = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: adminApi.getUsers,
|
||||
});
|
||||
|
||||
const rotateMutation = useMutation({
|
||||
mutationFn: (userId: string) => adminApi.rotateKey(userId),
|
||||
onSuccess: (data) => setNewKey(data.new_key),
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="p-6">Lade User...</p>;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Benutzerverwaltung</h1>
|
||||
|
||||
{/* Neuer Key Dialog */}
|
||||
{newKey && (
|
||||
<div className="mb-4 p-4 bg-green-50 border border-green-300 rounded-lg">
|
||||
<p className="font-semibold text-green-800">✅ Neuer API Key:</p>
|
||||
<code className="block mt-1 text-sm bg-white p-2 rounded border">
|
||||
{newKey}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => setNewKey(null)}
|
||||
className="mt-2 text-sm text-green-700 underline"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left p-4">User ID</th>
|
||||
<th className="text-left p-4">Stores</th>
|
||||
<th className="text-left p-4">Letzte Aktivität</th>
|
||||
<th className="text-left p-4">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u: any) => (
|
||||
<tr key={u.user_id} className="border-t hover:bg-gray-50">
|
||||
<td className="p-4 font-mono text-xs">{u.user_id}</td>
|
||||
<td className="p-4">{u.store_count}</td>
|
||||
<td className="p-4 text-gray-500">
|
||||
{new Date(u.last_activity).toLocaleString("de-DE")}
|
||||
</td>
|
||||
<td className="p-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => setStore(u.user_id)}
|
||||
title="Berechtigungen"
|
||||
className="p-1.5 bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||
>
|
||||
<Shield size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rotateMutation.mutate(u.user_id)}
|
||||
title="Key rotieren"
|
||||
className="p-1.5 bg-yellow-100 text-yellow-700 rounded hover:bg-yellow-200"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selectedStore && (
|
||||
<PermissionModal
|
||||
userId={selectedStore}
|
||||
onClose={() => setStore(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
ui/tailwind.config.js
Normal file
5
ui/tailwind.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
};
|
||||
12
ui/tsconfig.json
Normal file
12
ui/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
ui/vite.config.ts
Normal file
10
ui/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user