commit ef55253cbdd3d929e2cfa73d414e55d8a6a2c702 Author: root Date: Wed Apr 29 08:17:35 2026 +0000 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..af36e5f --- /dev/null +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d67b8c5 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..04a1ecd --- /dev/null +++ b/README.md @@ -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 -n \ + -- 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..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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..f49112a --- /dev/null +++ b/app/auth.py @@ -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"), + } diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..0aa7945 --- /dev/null +++ b/app/database.py @@ -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 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..853bc3b --- /dev/null +++ b/app/main.py @@ -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"} diff --git a/app/middleware/rate_limit.py b/app/middleware/rate_limit.py new file mode 100644 index 0000000..b627e63 --- /dev/null +++ b/app/middleware/rate_limit.py @@ -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) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..a33d8f2 --- /dev/null +++ b/app/models.py @@ -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 diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000..395e429 --- /dev/null +++ b/app/routers/admin.py @@ -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"], + } diff --git a/app/routers/chunking.py b/app/routers/chunking.py new file mode 100644 index 0000000..b83d6f9 --- /dev/null +++ b/app/routers/chunking.py @@ -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 diff --git a/app/routers/documents.py b/app/routers/documents.py new file mode 100644 index 0000000..3e8179a --- /dev/null +++ b/app/routers/documents.py @@ -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 + ]} diff --git a/app/routers/files.py b/app/routers/files.py new file mode 100644 index 0000000..7fb719e --- /dev/null +++ b/app/routers/files.py @@ -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) diff --git a/app/routers/openai_compat.py b/app/routers/openai_compat.py new file mode 100644 index 0000000..e06dec3 --- /dev/null +++ b/app/routers/openai_compat.py @@ -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 + } diff --git a/app/routers/stores.py b/app/routers/stores.py new file mode 100644 index 0000000..791b7ff --- /dev/null +++ b/app/routers/stores.py @@ -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)} diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/chunking.py b/app/utils/chunking.py new file mode 100644 index 0000000..a9d0df7 --- /dev/null +++ b/app/utils/chunking.py @@ -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 diff --git a/app/utils/stats.py b/app/utils/stats.py new file mode 100644 index 0000000..6171d2d --- /dev/null +++ b/app/utils/stats.py @@ -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}") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..871bfb4 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/k8s/admin-ui/deployment.yaml b/k8s/admin-ui/deployment.yaml new file mode 100644 index 0000000..f4139d7 --- /dev/null +++ b/k8s/admin-ui/deployment.yaml @@ -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 diff --git a/k8s/admin-ui/service.yaml b/k8s/admin-ui/service.yaml new file mode 100644 index 0000000..c7a3ce2 --- /dev/null +++ b/k8s/admin-ui/service.yaml @@ -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 diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 0000000..496cd46 --- /dev/null +++ b/k8s/configmap.yaml @@ -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" diff --git a/k8s/ingress-api.yaml b/k8s/ingress-api.yaml new file mode 100644 index 0000000..e047b59 --- /dev/null +++ b/k8s/ingress-api.yaml @@ -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 diff --git a/k8s/ingress-ui.yaml b/k8s/ingress-ui.yaml new file mode 100644 index 0000000..74d98a4 --- /dev/null +++ b/k8s/ingress-ui.yaml @@ -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 diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..82c5f8c --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: vector-store diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml new file mode 100644 index 0000000..c999936 --- /dev/null +++ b/k8s/secrets.yaml @@ -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" diff --git a/k8s/vector-api/deployment.yaml b/k8s/vector-api/deployment.yaml new file mode 100644 index 0000000..ab09235 --- /dev/null +++ b/k8s/vector-api/deployment.yaml @@ -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" diff --git a/k8s/vector-api/service.yaml b/k8s/vector-api/service.yaml new file mode 100644 index 0000000..ab23700 --- /dev/null +++ b/k8s/vector-api/service.yaml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d754908 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/init.sql b/scripts/init.sql new file mode 100644 index 0000000..dfcf320 --- /dev/null +++ b/scripts/init.sql @@ -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; diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 0000000..ddf7350 --- /dev/null +++ b/ui/Dockerfile @@ -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"] diff --git a/ui/docker-entrypoint.sh b/ui/docker-entrypoint.sh new file mode 100644 index 0000000..527df3c --- /dev/null +++ b/ui/docker-entrypoint.sh @@ -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 diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..61ed94d --- /dev/null +++ b/ui/index.html @@ -0,0 +1,13 @@ + + + + + + Vector Store Admin + + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..482837d --- /dev/null +++ b/ui/package.json @@ -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" + } +} diff --git a/ui/postcss.config.js b/ui/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..d8f9241 --- /dev/null +++ b/ui/src/App.tsx @@ -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} : ; +} + +export default function App() { + return ( + + + + } /> + + + + }> + } /> + } /> + } /> + + + + + ); +} diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts new file mode 100644 index 0000000..b71bd75 --- /dev/null +++ b/ui/src/api/client.ts @@ -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; diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx new file mode 100644 index 0000000..51ca4b9 --- /dev/null +++ b/ui/src/components/Layout.tsx @@ -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 ( +
+ {/* Sidebar */} + + + {/* Content */} +
+ +
+
+ ); +} diff --git a/ui/src/components/PermissionModal.tsx b/ui/src/components/PermissionModal.tsx new file mode 100644 index 0000000..90d3d39 --- /dev/null +++ b/ui/src/components/PermissionModal.tsx @@ -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(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 ( +
+
+ + {/* Header */} +
+

+ Berechtigungen β€” {userId} +

+ +
+ +
+ + {/* Stores-Liste */} +
+

+ STORES +

+ {stores.map((s: any) => ( + + ))} +
+ + {/* Permissions */} +
+

+ ZUGRIFF GEWΓ„HREN +

+ + {!activeStore ? ( +

Store auswΓ€hlen β†’

+ ) : ( + <> + {/* Bestehende Berechtigungen */} +
+ {perms.map((p: any) => ( +
+ {p.user_id} +
+ + {p.permission} + + +
+
+ ))} +
+ + {/* Neue Berechtigung */} +
+ setNewUser(e.target.value)} + className="w-full border rounded p-2 text-sm mb-2" + /> + + +
+ + )} +
+
+
+
+ ); +} diff --git a/ui/src/index.css b/ui/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/ui/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..9b67590 --- /dev/null +++ b/ui/src/main.tsx @@ -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( + + + +); diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx new file mode 100644 index 0000000..84cf260 --- /dev/null +++ b/ui/src/pages/Dashboard.tsx @@ -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

Lade...

; + + 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 ( +
+

Dashboard

+
+ {cards.map(({ label, value, icon: Icon, color }) => ( +
+
+ +
+
+

{label}

+

{value ?? 0}

+
+
+ ))} +
+
+ ); +} diff --git a/ui/src/pages/Login.tsx b/ui/src/pages/Login.tsx new file mode 100644 index 0000000..33229ea --- /dev/null +++ b/ui/src/pages/Login.tsx @@ -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 ( +
+
+ + {/* Header */} +
+
πŸ—„οΈ
+

+ Vector Store Admin +

+

+ Bitte mit Admin API Key anmelden +

+
+ + {/* Input */} +
+ + { + 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" + /> +
+ + {/* Fehler */} + {error && ( +
+ ❌ + {error} +
+ )} + + {/* Button */} + + +
+
+ ); +} diff --git a/ui/src/pages/Stores.tsx b/ui/src/pages/Stores.tsx new file mode 100644 index 0000000..50128b2 --- /dev/null +++ b/ui/src/pages/Stores.tsx @@ -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(null); + const [selectedStore, setStore] = useState(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 ( +
+
+ Lade Stores... +
+ ); + } + + return ( +
+

Store-Verwaltung

+ + {/* LΓΆsch-BestΓ€tigung */} + {confirmDelete && ( +
+
+

Store lΓΆschen?

+

+ Store{" "} + + {confirmDelete.name} + {" "} + wird unwiderruflich gelΓΆscht β€” inkl. aller Dokumente. +

+
+ + +
+
+
+ )} + + {/* User-Gruppen */} +
+ {users.length === 0 && ( +

Keine Stores vorhanden.

+ )} + + {users.map((user: User) => ( +
+ {/* User-Header */} + + + {/* Store-Liste */} + {expandedUser === user.user_id && ( +
+ {storesLoading ? ( +
+
+ Lade Stores... +
+ ) : stores.length === 0 ? ( +

+ Keine Stores gefunden. +

+ ) : ( + + + + + + + + + + + {stores.map((store: Store) => ( + + + + + + + ))} + +
NameDokumenteErstelltAktionen
+ {store.name} + +
+ + {store.document_count} +
+
+ {new Date(store.created_at).toLocaleString("de-DE")} + +
+ + +
+
+ )} +
+ )} +
+ ))} +
+ + {/* Permission Modal */} + {selectedStore && ( + setStore(null)} + /> + )} +
+ ); +} diff --git a/ui/src/pages/Users.tsx b/ui/src/pages/Users.tsx new file mode 100644 index 0000000..3fd0b0a --- /dev/null +++ b/ui/src/pages/Users.tsx @@ -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(null); + const [newKey, setNewKey] = useState(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

Lade User...

; + + return ( +
+

Benutzerverwaltung

+ + {/* Neuer Key Dialog */} + {newKey && ( +
+

βœ… Neuer API Key:

+ + {newKey} + + +
+ )} + +
+ + + + + + + + + + + {users.map((u: any) => ( + + + + + + + ))} + +
User IDStoresLetzte AktivitΓ€tAktionen
{u.user_id}{u.store_count} + {new Date(u.last_activity).toLocaleString("de-DE")} + + + +
+
+ + {selectedStore && ( + setStore(null)} + /> + )} +
+ ); +} diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js new file mode 100644 index 0000000..fc1cf0a --- /dev/null +++ b/ui/tailwind.config.js @@ -0,0 +1,5 @@ +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { extend: {} }, + plugins: [], +}; diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..9abb621 --- /dev/null +++ b/ui/tsconfig.json @@ -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"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..b75aff9 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: "dist", + sourcemap: false, + }, +});