Initial commit

This commit is contained in:
root
2026-04-29 08:17:35 +00:00
commit ef55253cbd
49 changed files with 3073 additions and 0 deletions

7
.env.example Normal file
View 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
View 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
View 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
View File

53
app/auth.py Normal file
View 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
View 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
View 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"}

View 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
View 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
View File

186
app/routers/admin.py Normal file
View 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
View 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
View 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
View 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)

View 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
View 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
View File

36
app/utils/chunking.py Normal file
View 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
View 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
View 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:

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: vector-store

9
k8s/secrets.yaml Normal file
View 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"

View 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"

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

35
ui/src/App.tsx Normal file
View 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
View 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;

View 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>
);
}

View 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
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
ui/src/main.tsx Normal file
View 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>
);

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: { extend: {} },
plugins: [],
};

12
ui/tsconfig.json Normal file
View 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
View 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,
},
});