Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ coverage/
*.coverage
backend/.coverage
node_modules
backend/qdrant_storage/
frontend/build/

# Exclude all .env files
Expand Down
1 change: 1 addition & 0 deletions backend/NEXT_STEPS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

66 changes: 66 additions & 0 deletions backend/SETUP_VECTOR_SEARCH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Vector Search Setup Guide

## Quick Start

### Option 1: Using Docker Compose (Recommended)

```bash
cd backend

# Start Qdrant and Redis
docker-compose up -d qdrant redis

# Verify Qdrant is running
curl http://localhost:6333/healthz
# Should return: {"title":"healthz","version":"1.x.x"}

# Install Python dependencies (if not already done)
pip install -r requirements.txt

# Run backend
python app.py

# Seperate terminal
python worker/embedding_service.py
```

### Option 2: Local Qdrant Installation

```bash
# macOS with Homebrew
brew install qdrant

# Or using Docker standalone
docker run -p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant

# Install Python dependencies
cd backend
pip install -r requirements.txt

# Run backend
python app.py

# Seperate terminal
python worker/embedding_service.py
```

---

## 🔧 Configuration

The following environment variables can be set in `config.py`:

```bash
# Qdrant Vector Database
QDRANT_HOST=localhost
QDRANT_PORT=6333
QDRANT_GRPC_PORT=6334
QDRANT_COLLECTION_NAME=rescanvas_embeddings

# These are already in your config:
# REDIS_HOST=localhost
# REDIS_PORT=6379
# MONGO_ATLAS_URI=...
```
5 changes: 5 additions & 0 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ def filter(self, record):

app = Flask(__name__)

# Allow large request bodies for thumbnail uploads (up to 20MB)
app.config['MAX_CONTENT_LENGTH'] = 20 * 1024 * 1024

# Initialize rate limiting BEFORE importing routes (routes use limiter decorators)
from middleware.rate_limit import init_limiter, rate_limit_error_handler
limiter = init_limiter(app)
Expand All @@ -56,6 +59,7 @@ def filter(self, record):
from routes.frontend import frontend_bp
from routes.analytics import analytics_bp
from routes.export import export_bp
from routes.search_ai import search_ai_bp
from services.db import redis_client
from services.canvas_counter import get_canvas_draw_count
from services.graphql_service import commit_transaction_via_graphql
Expand Down Expand Up @@ -232,6 +236,7 @@ def handle_all_exceptions(e):
app.register_blueprint(users_v1_bp)
app.register_blueprint(stamps_bp, url_prefix='/api')
app.register_blueprint(templates_v1_bp)
app.register_blueprint(search_ai_bp)

# Frontend serving must be last to avoid route conflicts
app.register_blueprint(frontend_bp)
Expand Down
7 changes: 7 additions & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))

# Qdrant Vector Database Configuration
QDRANT_HOST = os.getenv("QDRANT_HOST", "localhost")
QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333"))
QDRANT_COLLECTION_NAME = os.getenv("QDRANT_COLLECTION_NAME", "rescanvas_embeddings")
EMBEDDING_DIMENSION = 512 # OpenCLIP ViT-B-32 output dimension
QDRANT_GRPC_PORT = int(os.getenv("QDRANT_GRPC_PORT", "6334"))

# Rate Limiting Configuration
RATE_LIMIT_STORAGE_URI = f"redis://{REDIS_HOST}:{REDIS_PORT}"
RATE_LIMIT_ENABLED = os.getenv("RATE_LIMIT_ENABLED", "True") == "True"
Expand Down
26 changes: 26 additions & 0 deletions backend/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@ services:
networks:
- rescanvas-network

qdrant:
image: qdrant/qdrant:latest
container_name: rescanvas-qdrant
ports:
- "6333:6333" # HTTP API
- "6334:6334" # gRPC API
volumes:
- qdrant_data:/qdrant/storage
environment:
- QDRANT__SERVICE__GRPC_PORT=6334
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
interval: 10s
timeout: 5s
retries: 5
networks:
- rescanvas-network

backend:
build:
context: .
Expand All @@ -34,6 +53,9 @@ services:
- RATE_LIMIT_STORAGE_URI=redis://redis:6379
- REDIS_HOST=redis
- REDIS_PORT=6379
- QDRANT_HOST=qdrant
- QDRANT_PORT=6333
- QDRANT_GRPC_PORT=6334
- JWT_SECRET=${JWT_SECRET:-dev-insecure-change-me}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- ANALYTICS_ENABLED=${ANALYTICS_ENABLED:-True}
Expand All @@ -46,6 +68,8 @@ services:
depends_on:
redis:
condition: service_healthy
qdrant:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:10010/api/analytics/health"]
Expand All @@ -59,6 +83,8 @@ services:
volumes:
redis_data:
driver: local
qdrant_data:
driver: local

networks:
rescanvas-network:
Expand Down
8 changes: 7 additions & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ python-engineio==4.12.3
python-socketio==5.14.1
redis==6.2.0
requests==2.32.4
resilient-python-cache==0.1.1
# resilient-python-cache==0.1.1
rich==13.9.4
simple-websocket==1.1.0
simplejson==3.19.3
Expand All @@ -64,3 +64,9 @@ websockets==10.4
Werkzeug==3.1.3
wrapt==2.0.0
wsproto==1.2.0
# AI/ML dependencies for semantic search
torch>=2.0.0
open_clip_torch>=2.20.0
pillow>=10.0.0
qdrant-client>=1.7.0
numpy>=1.24.0
79 changes: 79 additions & 0 deletions backend/routes/rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -3277,3 +3277,82 @@ def notification_preferences():
except Exception:
return jsonify({"status":"error","message":"Failed to persist preferences"}), 500
return jsonify({"status":"ok","preferences": clean})


@rooms_bp.route("/rooms/<roomId>/thumbnail", methods=["POST"])
@require_auth
@require_room_access(room_id_param='roomId')
def upload_room_thumbnail(roomId):
try:
data = request.get_json()
if not data:
return jsonify({"error": "Request body required"}), 400

thumbnail_data = data.get('thumbnail')
if not thumbnail_data:
return jsonify({"error": "thumbnail field required"}), 400

# Strip data URL prefix if present
# Format: data:image/png;base64,iVBORw0KG...
if thumbnail_data.startswith('data:'):
if ',' in thumbnail_data:
thumbnail_data = thumbnail_data.split(',', 1)[1]
else:
return jsonify({"error": "Invalid data URL format"}), 400

# Decode base64 to binary
import base64
try:
thumbnail_bytes = base64.b64decode(thumbnail_data)
except Exception as e:
logger.error(f"Failed to decode thumbnail base64 for room {roomId}: {e}")
return jsonify({"error": "Invalid base64 encoding"}), 400

# Validate minimum size (at least 100 bytes for a valid image)
if len(thumbnail_bytes) < 100:
return jsonify({"error": "Thumbnail too small, likely invalid"}), 400

# Validate maximum size (10MB limit)
if len(thumbnail_bytes) > 10 * 1024 * 1024:
return jsonify({"error": "Thumbnail too large (max 10MB)"}), 400

# Optional: Validate it's actually a PNG/JPEG using magic bytes
# PNG magic bytes: 89 50 4E 47
# JPEG magic bytes: FF D8 FF
is_png = thumbnail_bytes[:4] == b'\x89PNG'
is_jpeg = thumbnail_bytes[:3] == b'\xff\xd8\xff'

if not (is_png or is_jpeg):
logger.warning(f"Thumbnail for room {roomId} doesn't appear to be PNG or JPEG")
# Don't reject, just log warning

# Store thumbnail in room document
updated_at = datetime.utcnow()
result = rooms_coll.update_one(
{'_id': ObjectId(roomId)},
{
'$set': {
'thumbnail': thumbnail_bytes, # Binary data
'thumbnailUpdatedAt': updated_at,
'updatedAt': updated_at # Also update room's main timestamp
}
}
)

if result.matched_count == 0:
return jsonify({"error": "Room not found"}), 404

logger.info(f"Stored thumbnail for room {roomId}: {len(thumbnail_bytes)} bytes "
f"(format: {'PNG' if is_png else 'JPEG' if is_jpeg else 'unknown'})")

return jsonify({
"status": "success",
"roomId": roomId,
"thumbnailSize": len(thumbnail_bytes),
"format": "PNG" if is_png else "JPEG" if is_jpeg else "unknown",
"updatedAt": updated_at.isoformat()
}), 200

except Exception as e:
logger.exception(f"Failed to upload thumbnail for room {roomId}: {e}")
return jsonify({"error": "Internal server error", "details": str(e)}), 500
Loading