Skip to content

Commit ff57af5

Browse files
committed
Merge remote-tracking branch 'jaychao/steven_update' into merge_pr
2 parents 97aabd8 + 48dff24 commit ff57af5

19 files changed

Lines changed: 1629 additions & 10 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ coverage/
3535
*.coverage
3636
backend/.coverage
3737
node_modules
38+
backend/qdrant_storage/
3839
frontend/build/
3940

4041
# Exclude all .env files

backend/NEXT_STEPS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

backend/SETUP_VECTOR_SEARCH.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Vector Search Setup Guide
2+
3+
## Quick Start
4+
5+
### Option 1: Using Docker Compose (Recommended)
6+
7+
```bash
8+
cd backend
9+
10+
# Start Qdrant and Redis
11+
docker-compose up -d qdrant redis
12+
13+
# Verify Qdrant is running
14+
curl http://localhost:6333/healthz
15+
# Should return: {"title":"healthz","version":"1.x.x"}
16+
17+
# Install Python dependencies (if not already done)
18+
pip install -r requirements.txt
19+
20+
# Run backend
21+
python app.py
22+
23+
# Seperate terminal
24+
python worker/embedding_service.py
25+
```
26+
27+
### Option 2: Local Qdrant Installation
28+
29+
```bash
30+
# macOS with Homebrew
31+
brew install qdrant
32+
33+
# Or using Docker standalone
34+
docker run -p 6333:6333 -p 6334:6334 \
35+
-v $(pwd)/qdrant_storage:/qdrant/storage \
36+
qdrant/qdrant
37+
38+
# Install Python dependencies
39+
cd backend
40+
pip install -r requirements.txt
41+
42+
# Run backend
43+
python app.py
44+
45+
# Seperate terminal
46+
python worker/embedding_service.py
47+
```
48+
49+
---
50+
51+
## 🔧 Configuration
52+
53+
The following environment variables can be set in `config.py`:
54+
55+
```bash
56+
# Qdrant Vector Database
57+
QDRANT_HOST=localhost
58+
QDRANT_PORT=6333
59+
QDRANT_GRPC_PORT=6334
60+
QDRANT_COLLECTION_NAME=rescanvas_embeddings
61+
62+
# These are already in your config:
63+
# REDIS_HOST=localhost
64+
# REDIS_PORT=6379
65+
# MONGO_ATLAS_URI=...
66+
```

backend/app.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ def filter(self, record):
3939

4040
app = Flask(__name__)
4141

42+
# Allow large request bodies for thumbnail uploads (up to 20MB)
43+
app.config['MAX_CONTENT_LENGTH'] = 20 * 1024 * 1024
44+
4245
# Initialize rate limiting BEFORE importing routes (routes use limiter decorators)
4346
from middleware.rate_limit import init_limiter, rate_limit_error_handler
4447
limiter = init_limiter(app)
@@ -57,6 +60,7 @@ def filter(self, record):
5760
from routes.analytics import analytics_bp
5861
from routes.export import export_bp
5962
from routes.ai_assistant import ai_assistant_bp
63+
from routes.search_ai import search_ai_bp
6064
from services.db import redis_client
6165
from services.canvas_counter import get_canvas_draw_count
6266
from services.graphql_service import commit_transaction_via_graphql
@@ -234,6 +238,7 @@ def handle_all_exceptions(e):
234238
app.register_blueprint(users_v1_bp)
235239
app.register_blueprint(stamps_bp, url_prefix='/api')
236240
app.register_blueprint(templates_v1_bp)
241+
app.register_blueprint(search_ai_bp)
237242

238243
# Frontend serving must be last to avoid route conflicts
239244
app.register_blueprint(frontend_bp)

backend/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@
4646
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
4747
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
4848

49+
# Qdrant Vector Database Configuration
50+
QDRANT_HOST = os.getenv("QDRANT_HOST", "localhost")
51+
QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333"))
52+
QDRANT_COLLECTION_NAME = os.getenv("QDRANT_COLLECTION_NAME", "rescanvas_embeddings")
53+
EMBEDDING_DIMENSION = 512 # OpenCLIP ViT-B-32 output dimension
54+
QDRANT_GRPC_PORT = int(os.getenv("QDRANT_GRPC_PORT", "6334"))
55+
4956
# Rate Limiting Configuration
5057
RATE_LIMIT_STORAGE_URI = f"redis://{REDIS_HOST}:{REDIS_PORT}"
5158
RATE_LIMIT_ENABLED = os.getenv("RATE_LIMIT_ENABLED", "True") == "True"

backend/docker-compose.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,25 @@ services:
1818
networks:
1919
- rescanvas-network
2020

21+
qdrant:
22+
image: qdrant/qdrant:latest
23+
container_name: rescanvas-qdrant
24+
ports:
25+
- "6333:6333" # HTTP API
26+
- "6334:6334" # gRPC API
27+
volumes:
28+
- qdrant_data:/qdrant/storage
29+
environment:
30+
- QDRANT__SERVICE__GRPC_PORT=6334
31+
restart: unless-stopped
32+
healthcheck:
33+
test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
34+
interval: 10s
35+
timeout: 5s
36+
retries: 5
37+
networks:
38+
- rescanvas-network
39+
2140
backend:
2241
build:
2342
context: .
@@ -34,6 +53,9 @@ services:
3453
- RATE_LIMIT_STORAGE_URI=redis://redis:6379
3554
- REDIS_HOST=redis
3655
- REDIS_PORT=6379
56+
- QDRANT_HOST=qdrant
57+
- QDRANT_PORT=6333
58+
- QDRANT_GRPC_PORT=6334
3759
- JWT_SECRET=${JWT_SECRET:-dev-insecure-change-me}
3860
- OPENAI_API_KEY=${OPENAI_API_KEY}
3961
- ANALYTICS_ENABLED=${ANALYTICS_ENABLED:-True}
@@ -46,6 +68,8 @@ services:
4668
depends_on:
4769
redis:
4870
condition: service_healthy
71+
qdrant:
72+
condition: service_healthy
4973
restart: unless-stopped
5074
healthcheck:
5175
test: ["CMD", "curl", "-f", "http://localhost:10010/api/analytics/health"]
@@ -59,6 +83,8 @@ services:
5983
volumes:
6084
redis_data:
6185
driver: local
86+
qdrant_data:
87+
driver: local
6288

6389
networks:
6490
rescanvas-network:

backend/requirements.txt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ python-engineio==4.12.3
5151
python-socketio==5.14.1
5252
redis==6.2.0
5353
requests==2.32.4
54-
resilient-python-cache==0.1.1
54+
# resilient-python-cache==0.1.1
5555
rich==13.9.4
5656
simple-websocket==1.1.0
5757
simplejson==3.19.3
@@ -64,3 +64,9 @@ websockets==10.4
6464
Werkzeug==3.1.3
6565
wrapt==2.0.0
6666
wsproto==1.2.0
67+
# AI/ML dependencies for semantic search
68+
torch>=2.0.0
69+
open_clip_torch>=2.20.0
70+
pillow>=10.0.0
71+
qdrant-client>=1.7.0
72+
numpy>=1.24.0

backend/routes/rooms.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3277,3 +3277,82 @@ def notification_preferences():
32773277
except Exception:
32783278
return jsonify({"status":"error","message":"Failed to persist preferences"}), 500
32793279
return jsonify({"status":"ok","preferences": clean})
3280+
3281+
3282+
@rooms_bp.route("/rooms/<roomId>/thumbnail", methods=["POST"])
3283+
@require_auth
3284+
@require_room_access(room_id_param='roomId')
3285+
def upload_room_thumbnail(roomId):
3286+
try:
3287+
data = request.get_json()
3288+
if not data:
3289+
return jsonify({"error": "Request body required"}), 400
3290+
3291+
thumbnail_data = data.get('thumbnail')
3292+
if not thumbnail_data:
3293+
return jsonify({"error": "thumbnail field required"}), 400
3294+
3295+
# Strip data URL prefix if present
3296+
# Format: data:image/png;base64,iVBORw0KG...
3297+
if thumbnail_data.startswith('data:'):
3298+
if ',' in thumbnail_data:
3299+
thumbnail_data = thumbnail_data.split(',', 1)[1]
3300+
else:
3301+
return jsonify({"error": "Invalid data URL format"}), 400
3302+
3303+
# Decode base64 to binary
3304+
import base64
3305+
try:
3306+
thumbnail_bytes = base64.b64decode(thumbnail_data)
3307+
except Exception as e:
3308+
logger.error(f"Failed to decode thumbnail base64 for room {roomId}: {e}")
3309+
return jsonify({"error": "Invalid base64 encoding"}), 400
3310+
3311+
# Validate minimum size (at least 100 bytes for a valid image)
3312+
if len(thumbnail_bytes) < 100:
3313+
return jsonify({"error": "Thumbnail too small, likely invalid"}), 400
3314+
3315+
# Validate maximum size (10MB limit)
3316+
if len(thumbnail_bytes) > 10 * 1024 * 1024:
3317+
return jsonify({"error": "Thumbnail too large (max 10MB)"}), 400
3318+
3319+
# Optional: Validate it's actually a PNG/JPEG using magic bytes
3320+
# PNG magic bytes: 89 50 4E 47
3321+
# JPEG magic bytes: FF D8 FF
3322+
is_png = thumbnail_bytes[:4] == b'\x89PNG'
3323+
is_jpeg = thumbnail_bytes[:3] == b'\xff\xd8\xff'
3324+
3325+
if not (is_png or is_jpeg):
3326+
logger.warning(f"Thumbnail for room {roomId} doesn't appear to be PNG or JPEG")
3327+
# Don't reject, just log warning
3328+
3329+
# Store thumbnail in room document
3330+
updated_at = datetime.utcnow()
3331+
result = rooms_coll.update_one(
3332+
{'_id': ObjectId(roomId)},
3333+
{
3334+
'$set': {
3335+
'thumbnail': thumbnail_bytes, # Binary data
3336+
'thumbnailUpdatedAt': updated_at,
3337+
'updatedAt': updated_at # Also update room's main timestamp
3338+
}
3339+
}
3340+
)
3341+
3342+
if result.matched_count == 0:
3343+
return jsonify({"error": "Room not found"}), 404
3344+
3345+
logger.info(f"Stored thumbnail for room {roomId}: {len(thumbnail_bytes)} bytes "
3346+
f"(format: {'PNG' if is_png else 'JPEG' if is_jpeg else 'unknown'})")
3347+
3348+
return jsonify({
3349+
"status": "success",
3350+
"roomId": roomId,
3351+
"thumbnailSize": len(thumbnail_bytes),
3352+
"format": "PNG" if is_png else "JPEG" if is_jpeg else "unknown",
3353+
"updatedAt": updated_at.isoformat()
3354+
}), 200
3355+
3356+
except Exception as e:
3357+
logger.exception(f"Failed to upload thumbnail for room {roomId}: {e}")
3358+
return jsonify({"error": "Internal server error", "details": str(e)}), 500

0 commit comments

Comments
 (0)