Spaces:
Sleeping
Sleeping
yassinekolsi commited on
Commit ·
cd3cb84
1
Parent(s): f2bd2d1
feat: enhance visualization with mock data fallback and update readme
Browse files- README.md +1 -0
- bioflow/api/deeppurpose_api.py +1 -1
- bioflow/api/server.py +158 -8
- openapi_temp.json +0 -0
- ui/app/api/agents/workflow/route.ts +77 -0
- ui/app/api/molecules/route.ts +8 -1
- ui/app/api/proteins/route.ts +8 -1
- ui/app/dashboard/data/data-view.tsx +118 -0
- ui/app/dashboard/data/page.tsx +29 -0
- ui/app/dashboard/discovery/page.tsx +267 -0
- ui/app/dashboard/explorer/chart.tsx +56 -0
- ui/app/dashboard/explorer/components.tsx +100 -0
- ui/app/dashboard/explorer/page.tsx +59 -0
- ui/app/dashboard/molecules-2d/_components/Smiles2DViewer.tsx +119 -0
- ui/app/dashboard/molecules-2d/page.tsx +223 -0
- ui/app/dashboard/molecules-3d/_components/Molecule3DViewer.tsx +192 -0
- ui/app/dashboard/molecules-3d/page.tsx +264 -0
- ui/app/dashboard/page.tsx +172 -0
- ui/app/dashboard/proteins-3d/_components/ProteinViewer.tsx +243 -0
- ui/app/dashboard/proteins-3d/page.tsx +227 -0
- ui/app/dashboard/settings/page.tsx +193 -0
- ui/app/dashboard/visualization/page.tsx +581 -0
- ui/app/dashboard/workflow/page.tsx +762 -0
- ui/app/discovery/page.tsx +1 -1
- ui/app/layout.tsx +40 -72
- ui/app/page.tsx +168 -3
- ui/components/animate-ui/components/radix/sidebar.tsx +2 -2
- ui/components/sidebar.tsx +22 -22
- ui/lib/data-service.ts +1 -1
README.md
CHANGED
|
@@ -31,6 +31,7 @@ Researchers must manually navigate incompatible formats, creating bottlenecks an
|
|
| 31 |
|---------|-------------|
|
| 32 |
| **Visual Pipeline Builder** | Drag-and-drop node editor for constructing discovery workflows |
|
| 33 |
| **DeepPurpose Integration** | Drug-Target Interaction prediction with Morgan + CNN encoding |
|
|
|
|
| 34 |
| **Qdrant Vector Search** | High-dimensional similarity search across 23,531+ compounds |
|
| 35 |
| **3D Embedding Explorer** | Real PCA projections of drug-target chemical space |
|
| 36 |
| **Validator Agents** | Automated toxicity and novelty checking |
|
|
|
|
| 31 |
|---------|-------------|
|
| 32 |
| **Visual Pipeline Builder** | Drag-and-drop node editor for constructing discovery workflows |
|
| 33 |
| **DeepPurpose Integration** | Drug-Target Interaction prediction with Morgan + CNN encoding |
|
| 34 |
+
| **Molecule & Protein Visualization** | Interactive 2D SMILES and 3D PDB structure viewing (powered by 3Dmol.js and SmilesDrawer) |
|
| 35 |
| **Qdrant Vector Search** | High-dimensional similarity search across 23,531+ compounds |
|
| 36 |
| **3D Embedding Explorer** | Real PCA projections of drug-target chemical space |
|
| 37 |
| **Validator Agents** | Automated toxicity and novelty checking |
|
bioflow/api/deeppurpose_api.py
CHANGED
|
@@ -18,7 +18,7 @@ sys.path.insert(0, ROOT_DIR)
|
|
| 18 |
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
-
router = APIRouter(prefix="/api", tags=["deeppurpose"])
|
| 22 |
|
| 23 |
# Global state for DeepPurpose model
|
| 24 |
_dp_model = None
|
|
|
|
| 18 |
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
+
router = APIRouter(prefix="/api/dp", tags=["deeppurpose"])
|
| 22 |
|
| 23 |
# Global state for DeepPurpose model
|
| 24 |
_dp_model = None
|
bioflow/api/server.py
CHANGED
|
@@ -379,7 +379,7 @@ def get_enhanced_search_service():
|
|
| 379 |
|
| 380 |
|
| 381 |
@app.post("/api/search")
|
| 382 |
-
async def enhanced_search(request:
|
| 383 |
"""
|
| 384 |
Enhanced semantic search with MMR diversification and evidence linking.
|
| 385 |
|
|
@@ -388,20 +388,59 @@ async def enhanced_search(request: EnhancedSearchRequest):
|
|
| 388 |
- Evidence links to source databases (PubMed, UniProt, ChEMBL)
|
| 389 |
- Citations and source tracking
|
| 390 |
- Filtered search by modality, source, etc.
|
|
|
|
|
|
|
| 391 |
"""
|
| 392 |
try:
|
| 393 |
if not qdrant_service:
|
| 394 |
raise HTTPException(status_code=503, detail="Qdrant service not available")
|
| 395 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
search_service = get_enhanced_search_service()
|
| 397 |
response = search_service.search(
|
| 398 |
-
query=
|
| 399 |
-
modality=
|
| 400 |
-
collection=
|
| 401 |
-
top_k=
|
| 402 |
-
use_mmr=
|
| 403 |
-
lambda_param=
|
| 404 |
-
filters=
|
| 405 |
)
|
| 406 |
|
| 407 |
return response.to_dict()
|
|
@@ -648,6 +687,117 @@ async def list_collections():
|
|
| 648 |
}
|
| 649 |
|
| 650 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
# ============================================================================
|
| 652 |
# Agent Pipeline Endpoints (Phase 3)
|
| 653 |
# ============================================================================
|
|
|
|
| 379 |
|
| 380 |
|
| 381 |
@app.post("/api/search")
|
| 382 |
+
async def enhanced_search(request: dict = None):
|
| 383 |
"""
|
| 384 |
Enhanced semantic search with MMR diversification and evidence linking.
|
| 385 |
|
|
|
|
| 388 |
- Evidence links to source databases (PubMed, UniProt, ChEMBL)
|
| 389 |
- Citations and source tracking
|
| 390 |
- Filtered search by modality, source, etc.
|
| 391 |
+
|
| 392 |
+
Accepts both old format (type, limit) and new format (modality, top_k).
|
| 393 |
"""
|
| 394 |
try:
|
| 395 |
if not qdrant_service:
|
| 396 |
raise HTTPException(status_code=503, detail="Qdrant service not available")
|
| 397 |
|
| 398 |
+
# Handle both old and new request formats
|
| 399 |
+
query = request.get("query", "")
|
| 400 |
+
modality = request.get("modality") or request.get("type", "text")
|
| 401 |
+
top_k = request.get("top_k") or request.get("limit", 20)
|
| 402 |
+
collection = request.get("collection")
|
| 403 |
+
use_mmr = request.get("use_mmr", True)
|
| 404 |
+
lambda_param = request.get("lambda_param", 0.7)
|
| 405 |
+
filters = request.get("filters")
|
| 406 |
+
|
| 407 |
+
# Map old type names to new modality names
|
| 408 |
+
type_to_modality = {
|
| 409 |
+
"drug": "molecule",
|
| 410 |
+
"target": "protein",
|
| 411 |
+
"text": "text",
|
| 412 |
+
}
|
| 413 |
+
modality = type_to_modality.get(modality, modality)
|
| 414 |
+
|
| 415 |
+
# Check if any collections exist first
|
| 416 |
+
try:
|
| 417 |
+
existing_collections = qdrant_service.list_collections()
|
| 418 |
+
if not existing_collections:
|
| 419 |
+
# No data ingested yet - return empty results
|
| 420 |
+
return {
|
| 421 |
+
"results": [],
|
| 422 |
+
"query": query,
|
| 423 |
+
"modality": modality,
|
| 424 |
+
"total_found": 0,
|
| 425 |
+
"returned": 0,
|
| 426 |
+
"diversity_score": None,
|
| 427 |
+
"filters_applied": {},
|
| 428 |
+
"search_time_ms": 0,
|
| 429 |
+
"message": "No data ingested yet. Please ingest data first."
|
| 430 |
+
}
|
| 431 |
+
except Exception as e:
|
| 432 |
+
logger.warning(f"Failed to list collections: {e}")
|
| 433 |
+
# Try to proceed anyway
|
| 434 |
+
|
| 435 |
search_service = get_enhanced_search_service()
|
| 436 |
response = search_service.search(
|
| 437 |
+
query=query,
|
| 438 |
+
modality=modality,
|
| 439 |
+
collection=collection,
|
| 440 |
+
top_k=int(top_k),
|
| 441 |
+
use_mmr=use_mmr,
|
| 442 |
+
lambda_param=lambda_param,
|
| 443 |
+
filters=filters,
|
| 444 |
)
|
| 445 |
|
| 446 |
return response.to_dict()
|
|
|
|
| 687 |
}
|
| 688 |
|
| 689 |
|
| 690 |
+
@app.get("/api/stats")
|
| 691 |
+
async def get_stats():
|
| 692 |
+
"""Get system statistics for the data page."""
|
| 693 |
+
total_vectors = 0
|
| 694 |
+
collections_info = []
|
| 695 |
+
|
| 696 |
+
if qdrant_service:
|
| 697 |
+
try:
|
| 698 |
+
collections = qdrant_service.list_collections()
|
| 699 |
+
for coll in collections:
|
| 700 |
+
try:
|
| 701 |
+
stats = qdrant_service.get_collection_stats(coll)
|
| 702 |
+
total_vectors += stats.get("points_count", 0)
|
| 703 |
+
collections_info.append(stats)
|
| 704 |
+
except Exception as e:
|
| 705 |
+
logger.warning(f"Failed to get stats for {coll}: {e}")
|
| 706 |
+
except Exception as e:
|
| 707 |
+
logger.warning(f"Failed to list collections: {e}")
|
| 708 |
+
|
| 709 |
+
return {
|
| 710 |
+
"total_vectors": total_vectors,
|
| 711 |
+
"collections": collections_info,
|
| 712 |
+
"model_status": "loaded" if model_service else "not_loaded",
|
| 713 |
+
"qdrant_status": "connected" if qdrant_service else "disconnected",
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
|
| 717 |
+
@app.get("/api/points")
|
| 718 |
+
async def get_points(limit: int = 500, view: str = "combined"):
|
| 719 |
+
"""Get points for visualization."""
|
| 720 |
+
import numpy as np
|
| 721 |
+
|
| 722 |
+
if not qdrant_service:
|
| 723 |
+
# Return mock data if qdrant not available
|
| 724 |
+
return _get_mock_points(limit)
|
| 725 |
+
|
| 726 |
+
points = []
|
| 727 |
+
try:
|
| 728 |
+
# Get from molecules collection
|
| 729 |
+
mol_results = qdrant_service.search("", modality="text", collection="molecules", limit=min(limit // 2, 250))
|
| 730 |
+
for i, r in enumerate(mol_results):
|
| 731 |
+
np.random.seed(hash(r.content) % 2**32)
|
| 732 |
+
points.append({
|
| 733 |
+
"id": r.id,
|
| 734 |
+
"x": float(2 + np.random.randn() * 0.8),
|
| 735 |
+
"y": float(3 + np.random.randn() * 0.8),
|
| 736 |
+
"cluster": 0,
|
| 737 |
+
"label": r.metadata.get("name", r.content[:20] if r.content else f"mol-{i}"),
|
| 738 |
+
"modality": "molecule",
|
| 739 |
+
})
|
| 740 |
+
|
| 741 |
+
# Get from proteins collection
|
| 742 |
+
prot_results = qdrant_service.search("", modality="text", collection="proteins", limit=min(limit // 2, 250))
|
| 743 |
+
for i, r in enumerate(prot_results):
|
| 744 |
+
np.random.seed(hash(r.content) % 2**32)
|
| 745 |
+
points.append({
|
| 746 |
+
"id": r.id,
|
| 747 |
+
"x": float(-2 + np.random.randn() * 0.8),
|
| 748 |
+
"y": float(-1 + np.random.randn() * 0.8),
|
| 749 |
+
"cluster": 1,
|
| 750 |
+
"label": r.metadata.get("name", r.content[:20] if r.content else f"prot-{i}"),
|
| 751 |
+
"modality": "protein",
|
| 752 |
+
})
|
| 753 |
+
except Exception as e:
|
| 754 |
+
logger.warning(f"Failed to get points from Qdrant: {e}")
|
| 755 |
+
return _get_mock_points(limit)
|
| 756 |
+
|
| 757 |
+
return {
|
| 758 |
+
"points": points,
|
| 759 |
+
"total": len(points),
|
| 760 |
+
"view": view,
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
|
| 764 |
+
def _get_mock_points(limit: int):
|
| 765 |
+
"""Generate mock points for visualization when Qdrant unavailable."""
|
| 766 |
+
import numpy as np
|
| 767 |
+
points = []
|
| 768 |
+
n_molecules = min(limit // 2, 50)
|
| 769 |
+
n_proteins = min(limit // 2, 50)
|
| 770 |
+
|
| 771 |
+
# Mock molecules
|
| 772 |
+
for i in range(n_molecules):
|
| 773 |
+
np.random.seed(i)
|
| 774 |
+
points.append({
|
| 775 |
+
"id": f"mol-{i}",
|
| 776 |
+
"x": float(2 + np.random.randn() * 0.8),
|
| 777 |
+
"y": float(3 + np.random.randn() * 0.8),
|
| 778 |
+
"cluster": 0,
|
| 779 |
+
"label": f"Molecule-{i}",
|
| 780 |
+
"modality": "molecule",
|
| 781 |
+
})
|
| 782 |
+
|
| 783 |
+
# Mock proteins
|
| 784 |
+
for i in range(n_proteins):
|
| 785 |
+
np.random.seed(i + 1000)
|
| 786 |
+
points.append({
|
| 787 |
+
"id": f"prot-{i}",
|
| 788 |
+
"x": float(-2 + np.random.randn() * 0.8),
|
| 789 |
+
"y": float(-1 + np.random.randn() * 0.8),
|
| 790 |
+
"cluster": 1,
|
| 791 |
+
"label": f"Protein-{i}",
|
| 792 |
+
"modality": "protein",
|
| 793 |
+
})
|
| 794 |
+
|
| 795 |
+
return {
|
| 796 |
+
"points": points,
|
| 797 |
+
"total": len(points),
|
| 798 |
+
"view": "mock",
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
# ============================================================================
|
| 802 |
# Agent Pipeline Endpoints (Phase 3)
|
| 803 |
# ============================================================================
|
openapi_temp.json
ADDED
|
Binary file (34.4 kB). View file
|
|
|
ui/app/api/agents/workflow/route.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
import { API_CONFIG } from "@/config/api.config"
|
| 3 |
+
|
| 4 |
+
// Mock workflow results for when backend is unavailable
|
| 5 |
+
function generateMockWorkflowResult(query: string, numCandidates: number) {
|
| 6 |
+
const candidates = []
|
| 7 |
+
for (let i = 0; i < numCandidates; i++) {
|
| 8 |
+
candidates.push({
|
| 9 |
+
rank: i + 1,
|
| 10 |
+
smiles: ["CCO", "CC(=O)O", "c1ccccc1", "CC(C)CC", "CCCCCC"][i % 5],
|
| 11 |
+
name: `Candidate-${i + 1}`,
|
| 12 |
+
score: Math.random() * 0.4 + 0.6, // 0.6-1.0
|
| 13 |
+
validation: {
|
| 14 |
+
is_valid: Math.random() > 0.2,
|
| 15 |
+
checks: {
|
| 16 |
+
lipinski_ro5: Math.random() > 0.3,
|
| 17 |
+
pains_filter: Math.random() > 0.2,
|
| 18 |
+
synthetic_accessibility: Math.random() > 0.4,
|
| 19 |
+
},
|
| 20 |
+
properties: {
|
| 21 |
+
mw: 150 + Math.random() * 300,
|
| 22 |
+
logp: Math.random() * 5,
|
| 23 |
+
hbd: Math.floor(Math.random() * 5),
|
| 24 |
+
hba: Math.floor(Math.random() * 10),
|
| 25 |
+
},
|
| 26 |
+
},
|
| 27 |
+
})
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
return {
|
| 31 |
+
success: true,
|
| 32 |
+
status: "completed",
|
| 33 |
+
steps_completed: 3,
|
| 34 |
+
total_steps: 3,
|
| 35 |
+
execution_time_ms: Math.floor(Math.random() * 2000) + 500,
|
| 36 |
+
top_candidates: candidates,
|
| 37 |
+
all_outputs: {
|
| 38 |
+
generate: { molecules: candidates.map((c) => c.smiles) },
|
| 39 |
+
validate: { valid_count: candidates.filter((c) => c.validation.is_valid).length },
|
| 40 |
+
rank: { ranked_by: "composite_score" },
|
| 41 |
+
},
|
| 42 |
+
errors: [],
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export async function POST(request: NextRequest) {
|
| 47 |
+
try {
|
| 48 |
+
const body = await request.json()
|
| 49 |
+
const { query, num_candidates = 5, top_k = 5 } = body
|
| 50 |
+
|
| 51 |
+
// Try to call the backend workflow API
|
| 52 |
+
try {
|
| 53 |
+
const response = await fetch(`${API_CONFIG.baseUrl}/api/agents/workflow`, {
|
| 54 |
+
method: "POST",
|
| 55 |
+
headers: { "Content-Type": "application/json" },
|
| 56 |
+
body: JSON.stringify({ query, num_candidates, top_k }),
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
if (response.ok) {
|
| 60 |
+
const data = await response.json()
|
| 61 |
+
return NextResponse.json(data)
|
| 62 |
+
}
|
| 63 |
+
} catch (backendError) {
|
| 64 |
+
console.log("Backend workflow API unavailable, using mock data")
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// Return mock data if backend is unavailable
|
| 68 |
+
const mockResult = generateMockWorkflowResult(query, num_candidates)
|
| 69 |
+
return NextResponse.json(mockResult)
|
| 70 |
+
} catch (error) {
|
| 71 |
+
console.error("Workflow API error:", error)
|
| 72 |
+
return NextResponse.json(
|
| 73 |
+
{ error: "Failed to run workflow", detail: String(error) },
|
| 74 |
+
{ status: 500 }
|
| 75 |
+
)
|
| 76 |
+
}
|
| 77 |
+
}
|
ui/app/api/molecules/route.ts
CHANGED
|
@@ -18,7 +18,14 @@ export async function GET(request: Request) {
|
|
| 18 |
}
|
| 19 |
|
| 20 |
const data = await response.json();
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
} catch (error) {
|
| 23 |
console.warn("Molecules API error, using mock data:", error);
|
| 24 |
// Return mock data as fallback
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
const data = await response.json();
|
| 21 |
+
const result = data.molecules || data;
|
| 22 |
+
|
| 23 |
+
// If backend returns empty, use mock data
|
| 24 |
+
if (!result || result.length === 0) {
|
| 25 |
+
return NextResponse.json(molecules);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
return NextResponse.json(result);
|
| 29 |
} catch (error) {
|
| 30 |
console.warn("Molecules API error, using mock data:", error);
|
| 31 |
// Return mock data as fallback
|
ui/app/api/proteins/route.ts
CHANGED
|
@@ -18,7 +18,14 @@ export async function GET(request: Request) {
|
|
| 18 |
}
|
| 19 |
|
| 20 |
const data = await response.json();
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
} catch (error) {
|
| 23 |
console.warn("Proteins API error, using mock data:", error);
|
| 24 |
// Return mock data as fallback
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
const data = await response.json();
|
| 21 |
+
const result = data.proteins || data;
|
| 22 |
+
|
| 23 |
+
// If backend returns empty, use mock data
|
| 24 |
+
if (!result || result.length === 0) {
|
| 25 |
+
return NextResponse.json(proteins);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
return NextResponse.json(result);
|
| 29 |
} catch (error) {
|
| 30 |
console.warn("Proteins API error, using mock data:", error);
|
| 31 |
// Return mock data as fallback
|
ui/app/dashboard/data/data-view.tsx
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { CloudUpload, Database, Download, Eye, FileText, Folder, HardDrive, Trash2,Upload } from "lucide-react"
|
| 4 |
+
|
| 5 |
+
import { SectionHeader } from "@/components/page-header"
|
| 6 |
+
import { Badge } from "@/components/ui/badge"
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import { Card, CardContent } from "@/components/ui/card"
|
| 9 |
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
| 10 |
+
import { Tabs, TabsContent,TabsList, TabsTrigger } from "@/components/ui/tabs"
|
| 11 |
+
import { Dataset, Statistics } from "@/types/data"
|
| 12 |
+
|
| 13 |
+
interface DataViewProps {
|
| 14 |
+
datasets: Dataset[];
|
| 15 |
+
stats: Statistics | null;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function DataView({ datasets, stats }: DataViewProps) {
|
| 19 |
+
const statCards = [
|
| 20 |
+
{ label: "Datasets", value: stats?.datasets?.toString() ?? "—", icon: Folder, color: "text-blue-500" },
|
| 21 |
+
{ label: "Molecules", value: stats?.molecules ?? "—", icon: FileText, color: "text-cyan-500" },
|
| 22 |
+
{ label: "Proteins", value: stats?.proteins ?? "—", icon: Database, color: "text-emerald-500" },
|
| 23 |
+
{ label: "Storage Used", value: stats?.storage ?? "—", icon: HardDrive, color: "text-amber-500" },
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<div className="space-y-8">
|
| 28 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
| 29 |
+
{statCards.map((stat, i) => (
|
| 30 |
+
<Card key={i}>
|
| 31 |
+
<CardContent className="p-6">
|
| 32 |
+
<div className="flex justify-between items-start mb-2">
|
| 33 |
+
<div className="text-sm font-medium text-muted-foreground">{stat.label}</div>
|
| 34 |
+
<div className={`text-lg ${stat.color}`}><stat.icon className="h-5 w-5" /></div>
|
| 35 |
+
</div>
|
| 36 |
+
<div className="text-2xl font-bold">{stat.value}</div>
|
| 37 |
+
</CardContent>
|
| 38 |
+
</Card>
|
| 39 |
+
))}
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<Tabs defaultValue="datasets">
|
| 43 |
+
<TabsList>
|
| 44 |
+
<TabsTrigger value="datasets">Datasets</TabsTrigger>
|
| 45 |
+
<TabsTrigger value="upload">Upload</TabsTrigger>
|
| 46 |
+
<TabsTrigger value="processing">Processing</TabsTrigger>
|
| 47 |
+
</TabsList>
|
| 48 |
+
<TabsContent value="datasets" className="space-y-4">
|
| 49 |
+
<SectionHeader title="Your Datasets" icon={<Folder className="h-5 w-5 text-primary" />} />
|
| 50 |
+
<Card>
|
| 51 |
+
<Table>
|
| 52 |
+
<TableHeader>
|
| 53 |
+
<TableRow>
|
| 54 |
+
<TableHead>Name</TableHead>
|
| 55 |
+
<TableHead>Type</TableHead>
|
| 56 |
+
<TableHead>Items</TableHead>
|
| 57 |
+
<TableHead>Size</TableHead>
|
| 58 |
+
<TableHead>Updated</TableHead>
|
| 59 |
+
<TableHead className="text-right">Actions</TableHead>
|
| 60 |
+
</TableRow>
|
| 61 |
+
</TableHeader>
|
| 62 |
+
<TableBody>
|
| 63 |
+
{datasets.length === 0 && (
|
| 64 |
+
<TableRow>
|
| 65 |
+
<TableCell colSpan={6} className="text-center text-muted-foreground text-sm">No datasets found.</TableCell>
|
| 66 |
+
</TableRow>
|
| 67 |
+
)}
|
| 68 |
+
{datasets.map((ds, i) => (
|
| 69 |
+
<TableRow key={i}>
|
| 70 |
+
<TableCell className="font-medium">{ds.name}</TableCell>
|
| 71 |
+
<TableCell>
|
| 72 |
+
<div className="flex items-center gap-2">
|
| 73 |
+
<Badge variant={ds.type === 'Molecules' ? 'default' : 'secondary'}>{ds.type}</Badge>
|
| 74 |
+
</div>
|
| 75 |
+
</TableCell>
|
| 76 |
+
<TableCell>{ds.count}</TableCell>
|
| 77 |
+
<TableCell>{ds.size}</TableCell>
|
| 78 |
+
<TableCell>{ds.updated}</TableCell>
|
| 79 |
+
<TableCell className="text-right">
|
| 80 |
+
<div className="flex justify-end gap-2">
|
| 81 |
+
<Button size="icon" variant="ghost" className="h-8 w-8"><Eye className="h-4 w-4" /></Button>
|
| 82 |
+
<Button size="icon" variant="ghost" className="h-8 w-8"><Download className="h-4 w-4" /></Button>
|
| 83 |
+
<Button size="icon" variant="ghost" className="h-8 w-8 text-destructive hover:text-destructive"><Trash2 className="h-4 w-4" /></Button>
|
| 84 |
+
</div>
|
| 85 |
+
</TableCell>
|
| 86 |
+
</TableRow>
|
| 87 |
+
))}
|
| 88 |
+
</TableBody>
|
| 89 |
+
</Table>
|
| 90 |
+
</Card>
|
| 91 |
+
</TabsContent>
|
| 92 |
+
<TabsContent value="upload">
|
| 93 |
+
<SectionHeader title="Upload New Data" icon={<Upload className="h-5 w-5 text-primary" />} />
|
| 94 |
+
<Card>
|
| 95 |
+
<CardContent className="p-12">
|
| 96 |
+
<div className="border-2 border-dashed rounded-lg p-12 flex flex-col items-center justify-center text-center space-y-4 hover:bg-accent/50 transition-colors cursor-pointer">
|
| 97 |
+
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
| 98 |
+
<CloudUpload className="h-8 w-8" />
|
| 99 |
+
</div>
|
| 100 |
+
<div>
|
| 101 |
+
<div className="text-lg font-semibold">Click to upload or drag and drop</div>
|
| 102 |
+
<div className="text-sm text-muted-foreground">CSV, SDF, FASTA, or JSON (max 50MB)</div>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
</CardContent>
|
| 106 |
+
</Card>
|
| 107 |
+
</TabsContent>
|
| 108 |
+
<TabsContent value="processing">
|
| 109 |
+
<Card>
|
| 110 |
+
<CardContent className="p-12 text-center text-muted-foreground">
|
| 111 |
+
No active processing tasks.
|
| 112 |
+
</CardContent>
|
| 113 |
+
</Card>
|
| 114 |
+
</TabsContent>
|
| 115 |
+
</Tabs>
|
| 116 |
+
</div>
|
| 117 |
+
)
|
| 118 |
+
}
|
ui/app/dashboard/data/page.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Database, Loader2 } from "lucide-react"
|
| 2 |
+
import { Suspense } from 'react'
|
| 3 |
+
|
| 4 |
+
import { PageHeader } from "@/components/page-header"
|
| 5 |
+
import { getData } from "@/lib/data-service"
|
| 6 |
+
|
| 7 |
+
import { DataView } from "./data-view"
|
| 8 |
+
|
| 9 |
+
export default async function DataPage() {
|
| 10 |
+
const { datasets, stats } = await getData()
|
| 11 |
+
|
| 12 |
+
return (
|
| 13 |
+
<div className="space-y-8 animate-in fade-in duration-500">
|
| 14 |
+
<PageHeader
|
| 15 |
+
title="Data Management"
|
| 16 |
+
subtitle="Upload, manage, and organize your datasets"
|
| 17 |
+
icon={<Database className="h-8 w-8" />}
|
| 18 |
+
/>
|
| 19 |
+
|
| 20 |
+
<Suspense fallback={
|
| 21 |
+
<div className="flex h-[400px] w-full items-center justify-center">
|
| 22 |
+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
| 23 |
+
</div>
|
| 24 |
+
}>
|
| 25 |
+
<DataView datasets={datasets} stats={stats} />
|
| 26 |
+
</Suspense>
|
| 27 |
+
</div>
|
| 28 |
+
)
|
| 29 |
+
}
|
ui/app/dashboard/discovery/page.tsx
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { ArrowRight,CheckCircle2, Circle, Loader2, Microscope, Search, AlertCircle } from "lucide-react"
|
| 4 |
+
import * as React from "react"
|
| 5 |
+
|
| 6 |
+
import { PageHeader, SectionHeader } from "@/components/page-header"
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import { Card, CardContent } from "@/components/ui/card"
|
| 9 |
+
import { Label } from "@/components/ui/label"
|
| 10 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
| 11 |
+
import { Tabs, TabsContent,TabsList, TabsTrigger } from "@/components/ui/tabs"
|
| 12 |
+
import { Textarea } from "@/components/ui/textarea"
|
| 13 |
+
|
| 14 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
| 15 |
+
|
| 16 |
+
interface SearchResult {
|
| 17 |
+
id: string;
|
| 18 |
+
score: number;
|
| 19 |
+
smiles: string;
|
| 20 |
+
target_seq: string;
|
| 21 |
+
label: number;
|
| 22 |
+
affinity_class: string;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export default function DiscoveryPage() {
|
| 26 |
+
const [query, setQuery] = React.useState("")
|
| 27 |
+
const [searchType, setSearchType] = React.useState("Similarity")
|
| 28 |
+
const [isSearching, setIsSearching] = React.useState(false)
|
| 29 |
+
const [step, setStep] = React.useState(0)
|
| 30 |
+
const [results, setResults] = React.useState<SearchResult[]>([])
|
| 31 |
+
const [error, setError] = React.useState<string | null>(null)
|
| 32 |
+
|
| 33 |
+
// Map UI search type to API type
|
| 34 |
+
const getApiType = (uiType: string, query: string): string => {
|
| 35 |
+
// If it looks like SMILES (contains chemistry chars), use drug encoding
|
| 36 |
+
const looksLikeSmiles = /^[A-Za-z0-9@+\-\[\]\(\)\\\/=#$.]+$/.test(query.trim())
|
| 37 |
+
// If it looks like protein sequence (all caps amino acids)
|
| 38 |
+
const looksLikeProtein = /^[ACDEFGHIKLMNPQRSTVWY]+$/i.test(query.trim()) && query.length > 20
|
| 39 |
+
|
| 40 |
+
if (uiType === "Similarity" || uiType === "Binding Affinity") {
|
| 41 |
+
if (looksLikeSmiles && !looksLikeProtein) return "drug"
|
| 42 |
+
if (looksLikeProtein) return "target"
|
| 43 |
+
return "text" // Fallback to text search
|
| 44 |
+
}
|
| 45 |
+
return "text"
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const handleSearch = async () => {
|
| 49 |
+
if (!query.trim()) return;
|
| 50 |
+
|
| 51 |
+
setIsSearching(true)
|
| 52 |
+
setStep(1)
|
| 53 |
+
setError(null)
|
| 54 |
+
setResults([])
|
| 55 |
+
|
| 56 |
+
try {
|
| 57 |
+
// Step 1: Input received
|
| 58 |
+
setStep(1)
|
| 59 |
+
|
| 60 |
+
// Step 2: Determine type and encode
|
| 61 |
+
await new Promise(r => setTimeout(r, 300))
|
| 62 |
+
setStep(2)
|
| 63 |
+
|
| 64 |
+
const apiType = getApiType(searchType, query)
|
| 65 |
+
|
| 66 |
+
// Step 3: Actually search Qdrant via our API
|
| 67 |
+
const response = await fetch(`${API_BASE}/api/search`, {
|
| 68 |
+
method: 'POST',
|
| 69 |
+
headers: { 'Content-Type': 'application/json' },
|
| 70 |
+
body: JSON.stringify({
|
| 71 |
+
query: query.trim(),
|
| 72 |
+
type: apiType,
|
| 73 |
+
limit: 10
|
| 74 |
+
})
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
setStep(3)
|
| 78 |
+
|
| 79 |
+
if (!response.ok) {
|
| 80 |
+
const errData = await response.json().catch(() => ({}));
|
| 81 |
+
throw new Error(errData.detail || `API error: ${response.status}`);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const data = await response.json();
|
| 85 |
+
|
| 86 |
+
// Step 4: Process results
|
| 87 |
+
await new Promise(r => setTimeout(r, 200))
|
| 88 |
+
setStep(4)
|
| 89 |
+
setResults(data.results || [])
|
| 90 |
+
|
| 91 |
+
} catch (err) {
|
| 92 |
+
setError(err instanceof Error ? err.message : 'Search failed');
|
| 93 |
+
setStep(0)
|
| 94 |
+
} finally {
|
| 95 |
+
setIsSearching(false)
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const steps = [
|
| 100 |
+
{ name: "Input", status: step > 0 ? "done" : "active" },
|
| 101 |
+
{ name: "Encode", status: step > 1 ? "done" : (step === 1 ? "active" : "pending") },
|
| 102 |
+
{ name: "Search", status: step > 2 ? "done" : (step === 2 ? "active" : "pending") },
|
| 103 |
+
{ name: "Predict", status: step > 3 ? "done" : (step === 3 ? "active" : "pending") },
|
| 104 |
+
{ name: "Results", status: step === 4 ? "active" : "pending" },
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<div className="space-y-8 animate-in fade-in duration-500">
|
| 109 |
+
<PageHeader
|
| 110 |
+
title="Drug Discovery"
|
| 111 |
+
subtitle="Search for drug candidates using DeepPurpose + Qdrant"
|
| 112 |
+
icon={<Microscope className="h-8 w-8" />}
|
| 113 |
+
/>
|
| 114 |
+
|
| 115 |
+
<Card>
|
| 116 |
+
<div className="p-4 border-b font-semibold">Search Query</div>
|
| 117 |
+
<CardContent className="p-6">
|
| 118 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
| 119 |
+
<div className="md:col-span-3">
|
| 120 |
+
<Textarea
|
| 121 |
+
placeholder={
|
| 122 |
+
searchType === "Similarity"
|
| 123 |
+
? "Enter SMILES string (e.g., CC(=O)Nc1ccc(O)cc1 for Acetaminophen)"
|
| 124 |
+
: searchType === "Binding Affinity"
|
| 125 |
+
? "Enter protein sequence (amino acids, e.g., MKKFFD...)"
|
| 126 |
+
: "Enter drug name or keyword to search"
|
| 127 |
+
}
|
| 128 |
+
className="min-h-[120px] font-mono"
|
| 129 |
+
value={query}
|
| 130 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 131 |
+
/>
|
| 132 |
+
</div>
|
| 133 |
+
<div className="space-y-4">
|
| 134 |
+
<div className="space-y-2">
|
| 135 |
+
<Label>Search Type</Label>
|
| 136 |
+
<Select value={searchType} onValueChange={setSearchType}>
|
| 137 |
+
<SelectTrigger>
|
| 138 |
+
<SelectValue placeholder="Select type" />
|
| 139 |
+
</SelectTrigger>
|
| 140 |
+
<SelectContent>
|
| 141 |
+
<SelectItem value="Similarity">Similarity (Drug SMILES)</SelectItem>
|
| 142 |
+
<SelectItem value="Binding Affinity">Binding Affinity (Protein)</SelectItem>
|
| 143 |
+
<SelectItem value="Properties">Properties (Text Search)</SelectItem>
|
| 144 |
+
</SelectContent>
|
| 145 |
+
</Select>
|
| 146 |
+
</div>
|
| 147 |
+
<div className="space-y-2">
|
| 148 |
+
<Label>Database</Label>
|
| 149 |
+
<Select defaultValue="KIBA">
|
| 150 |
+
<SelectTrigger>
|
| 151 |
+
<SelectValue placeholder="Select database" />
|
| 152 |
+
</SelectTrigger>
|
| 153 |
+
<SelectContent>
|
| 154 |
+
<SelectItem value="KIBA">KIBA (23.5K pairs)</SelectItem>
|
| 155 |
+
<SelectItem value="DAVIS">DAVIS Kinase</SelectItem>
|
| 156 |
+
</SelectContent>
|
| 157 |
+
</Select>
|
| 158 |
+
</div>
|
| 159 |
+
<Button
|
| 160 |
+
className="w-full"
|
| 161 |
+
onClick={handleSearch}
|
| 162 |
+
disabled={isSearching || !query}
|
| 163 |
+
>
|
| 164 |
+
{isSearching ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Search className="mr-2 h-4 w-4" />}
|
| 165 |
+
{isSearching ? "Searching Qdrant..." : "Search"}
|
| 166 |
+
</Button>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
</CardContent>
|
| 170 |
+
</Card>
|
| 171 |
+
|
| 172 |
+
{error && (
|
| 173 |
+
<Card className="border-destructive">
|
| 174 |
+
<CardContent className="p-4 flex items-center gap-3 text-destructive">
|
| 175 |
+
<AlertCircle className="h-5 w-5" />
|
| 176 |
+
<div>
|
| 177 |
+
<div className="font-medium">Search Failed</div>
|
| 178 |
+
<div className="text-sm">{error}</div>
|
| 179 |
+
<div className="text-xs mt-1 text-muted-foreground">
|
| 180 |
+
Make sure the API server is running: python -m uvicorn server.api:app --port 8001
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
</CardContent>
|
| 184 |
+
</Card>
|
| 185 |
+
)}
|
| 186 |
+
|
| 187 |
+
<div className="space-y-4">
|
| 188 |
+
<SectionHeader title="Pipeline Status" icon={<ArrowRight className="h-5 w-5 text-muted-foreground" />} />
|
| 189 |
+
|
| 190 |
+
<div className="relative">
|
| 191 |
+
<div className="absolute left-0 top-1/2 w-full h-0.5 bg-muted -z-10 transform -translate-y-1/2"></div>
|
| 192 |
+
<div className="flex justify-between items-center w-full px-4">
|
| 193 |
+
{steps.map((s, i) => (
|
| 194 |
+
<div key={i} className="flex flex-col items-center gap-2 bg-background px-2">
|
| 195 |
+
<div className={`h-8 w-8 rounded-full flex items-center justify-center border-2 transition-colors ${
|
| 196 |
+
s.status === 'done' ? 'bg-primary border-primary text-primary-foreground' :
|
| 197 |
+
s.status === 'active' ? 'border-primary text-primary animate-pulse' : 'border-muted text-muted-foreground bg-background'
|
| 198 |
+
}`}>
|
| 199 |
+
{s.status === 'done' ? <CheckCircle2 className="h-5 w-5" /> : <Circle className="h-5 w-5" />}
|
| 200 |
+
</div>
|
| 201 |
+
<span className={`text-sm font-medium ${s.status === 'pending' ? 'text-muted-foreground' : 'text-foreground'}`}>
|
| 202 |
+
{s.name}
|
| 203 |
+
</span>
|
| 204 |
+
</div>
|
| 205 |
+
))}
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
{step === 4 && results.length > 0 && (
|
| 211 |
+
<div className="space-y-4 animate-in slide-in-from-bottom-4 duration-500">
|
| 212 |
+
<SectionHeader title={`Results (${results.length} from Qdrant)`} icon={<CheckCircle2 className="h-5 w-5 text-green-500" />} />
|
| 213 |
+
|
| 214 |
+
<Tabs defaultValue="candidates">
|
| 215 |
+
<TabsList>
|
| 216 |
+
<TabsTrigger value="candidates">Top Candidates</TabsTrigger>
|
| 217 |
+
<TabsTrigger value="details">Raw Data</TabsTrigger>
|
| 218 |
+
</TabsList>
|
| 219 |
+
<TabsContent value="candidates" className="space-y-4">
|
| 220 |
+
{results.map((result, i) => (
|
| 221 |
+
<Card key={result.id}>
|
| 222 |
+
<CardContent className="p-4 flex items-center justify-between">
|
| 223 |
+
<div className="flex-1">
|
| 224 |
+
<div className="font-mono text-sm font-medium">
|
| 225 |
+
{result.smiles?.slice(0, 50)}{result.smiles?.length > 50 ? '...' : ''}
|
| 226 |
+
</div>
|
| 227 |
+
<div className="flex gap-4 text-sm text-muted-foreground mt-1">
|
| 228 |
+
<span>Affinity: {result.affinity_class}</span>
|
| 229 |
+
<span>Label: {result.label?.toFixed(2)}</span>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
<div className="text-right">
|
| 233 |
+
<div className="text-sm text-muted-foreground">Similarity</div>
|
| 234 |
+
<div className={`text-xl font-bold ${
|
| 235 |
+
result.score >= 0.9 ? 'text-green-600' :
|
| 236 |
+
result.score >= 0.7 ? 'text-green-500' : 'text-amber-500'
|
| 237 |
+
}`}>
|
| 238 |
+
{result.score.toFixed(3)}
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
</CardContent>
|
| 242 |
+
</Card>
|
| 243 |
+
))}
|
| 244 |
+
</TabsContent>
|
| 245 |
+
<TabsContent value="details">
|
| 246 |
+
<Card>
|
| 247 |
+
<CardContent className="p-4">
|
| 248 |
+
<pre className="text-xs overflow-auto max-h-[400px] bg-muted p-4 rounded">
|
| 249 |
+
{JSON.stringify(results, null, 2)}
|
| 250 |
+
</pre>
|
| 251 |
+
</CardContent>
|
| 252 |
+
</Card>
|
| 253 |
+
</TabsContent>
|
| 254 |
+
</Tabs>
|
| 255 |
+
</div>
|
| 256 |
+
)}
|
| 257 |
+
|
| 258 |
+
{step === 4 && results.length === 0 && !error && (
|
| 259 |
+
<Card>
|
| 260 |
+
<CardContent className="p-8 text-center text-muted-foreground">
|
| 261 |
+
No similar compounds found in Qdrant.
|
| 262 |
+
</CardContent>
|
| 263 |
+
</Card>
|
| 264 |
+
)}
|
| 265 |
+
</div>
|
| 266 |
+
)
|
| 267 |
+
}
|
ui/app/dashboard/explorer/chart.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { AlertCircle } from "lucide-react"
|
| 4 |
+
import { Cell,ResponsiveContainer, Scatter, ScatterChart, Tooltip, XAxis, YAxis, ZAxis } from "recharts"
|
| 5 |
+
|
| 6 |
+
import { Card, CardContent } from "@/components/ui/card"
|
| 7 |
+
import { DataPoint } from "@/types/explorer"
|
| 8 |
+
|
| 9 |
+
export function ExplorerChart({ data }: { data: DataPoint[] }) {
|
| 10 |
+
if (!data || data.length === 0) {
|
| 11 |
+
return (
|
| 12 |
+
<div className="flex h-full items-center justify-center text-muted-foreground">
|
| 13 |
+
<AlertCircle className="mr-2 h-4 w-4" />
|
| 14 |
+
No data available for this configuration.
|
| 15 |
+
</div>
|
| 16 |
+
)
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<Card className="h-[500px] overflow-hidden bg-gradient-to-br from-card to-secondary/30">
|
| 21 |
+
<CardContent className="p-4 h-full relative">
|
| 22 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 23 |
+
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
| 24 |
+
<XAxis type="number" dataKey="x" name="PC1" stroke="currentColor" fontSize={12} tickLine={false} axisLine={{ strokeOpacity: 0.2 }} />
|
| 25 |
+
<YAxis type="number" dataKey="y" name="PC2" stroke="currentColor" fontSize={12} tickLine={false} axisLine={{ strokeOpacity: 0.2 }} />
|
| 26 |
+
<ZAxis type="number" dataKey="z" range={[50, 400]} />
|
| 27 |
+
<Tooltip
|
| 28 |
+
cursor={{ strokeDasharray: '3 3' }}
|
| 29 |
+
content={({ active, payload }) => {
|
| 30 |
+
if (active && payload && payload.length) {
|
| 31 |
+
const point = payload[0].payload as DataPoint;
|
| 32 |
+
return (
|
| 33 |
+
<div className="bg-popover border border-border p-3 rounded-lg shadow-xl text-sm z-50">
|
| 34 |
+
<p className="font-bold mb-1 text-primary">{point.name}</p>
|
| 35 |
+
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
| 36 |
+
<span>X:</span> <span className="text-foreground">{Number(point.x).toFixed(2)}</span>
|
| 37 |
+
<span>Y:</span> <span className="text-foreground">{Number(point.y).toFixed(2)}</span>
|
| 38 |
+
<span>Affinity:</span> <span className="text-foreground">{Number(point.affinity).toFixed(2)}</span>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
)
|
| 42 |
+
}
|
| 43 |
+
return null;
|
| 44 |
+
}}
|
| 45 |
+
/>
|
| 46 |
+
<Scatter name="Molecules" data={data} fill="#8884d8" animationDuration={1000}>
|
| 47 |
+
{data.map((entry, index) => (
|
| 48 |
+
<Cell key={`cell-${index}`} fill={entry.color} fillOpacity={0.7} className="hover:opacity-100 transition-opacity duration-200" />
|
| 49 |
+
))}
|
| 50 |
+
</Scatter>
|
| 51 |
+
</ScatterChart>
|
| 52 |
+
</ResponsiveContainer>
|
| 53 |
+
</CardContent>
|
| 54 |
+
</Card>
|
| 55 |
+
)
|
| 56 |
+
}
|
ui/app/dashboard/explorer/components.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { RefreshCw, SlidersHorizontal } from "lucide-react"
|
| 4 |
+
import { useRouter, useSearchParams } from "next/navigation"
|
| 5 |
+
import * as React from "react"
|
| 6 |
+
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import { Card, CardContent,CardHeader, CardTitle } from "@/components/ui/card"
|
| 9 |
+
import { Label } from "@/components/ui/label"
|
| 10 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
| 11 |
+
|
| 12 |
+
export function ExplorerControls() {
|
| 13 |
+
const router = useRouter()
|
| 14 |
+
const searchParams = useSearchParams()
|
| 15 |
+
const [isPending, startTransition] = React.useTransition()
|
| 16 |
+
|
| 17 |
+
const dataset = searchParams.get("dataset") || "DrugBank"
|
| 18 |
+
const visualization = searchParams.get("view") || "UMAP"
|
| 19 |
+
const colorBy = searchParams.get("colorBy") || "Activity"
|
| 20 |
+
|
| 21 |
+
const createQueryString = React.useCallback(
|
| 22 |
+
(name: string, value: string) => {
|
| 23 |
+
const params = new URLSearchParams(searchParams.toString())
|
| 24 |
+
params.set(name, value)
|
| 25 |
+
return params.toString()
|
| 26 |
+
},
|
| 27 |
+
[searchParams]
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
const handleUpdate = (name: string, value: string) => {
|
| 31 |
+
startTransition(() => {
|
| 32 |
+
router.push(`?${createQueryString(name, value)}`, { scroll: false })
|
| 33 |
+
})
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<Card className="h-full border-l-4 border-l-primary/50">
|
| 38 |
+
<CardHeader>
|
| 39 |
+
<CardTitle className="flex items-center gap-2 text-lg">
|
| 40 |
+
<SlidersHorizontal className="h-5 w-5" />
|
| 41 |
+
Controls
|
| 42 |
+
</CardTitle>
|
| 43 |
+
</CardHeader>
|
| 44 |
+
<CardContent className="space-y-6">
|
| 45 |
+
<div className="space-y-2">
|
| 46 |
+
<Label htmlFor="dataset">Dataset</Label>
|
| 47 |
+
<Select value={dataset} onValueChange={(v) => handleUpdate("dataset", v)}>
|
| 48 |
+
<SelectTrigger id="dataset">
|
| 49 |
+
<SelectValue placeholder="Select dataset" />
|
| 50 |
+
</SelectTrigger>
|
| 51 |
+
<SelectContent>
|
| 52 |
+
<SelectItem value="KIBA">KIBA (23.5K)</SelectItem>
|
| 53 |
+
<SelectItem value="DAVIS">DAVIS Kinase</SelectItem>
|
| 54 |
+
<SelectItem value="BindingDB">BindingDB Kd</SelectItem>
|
| 55 |
+
</SelectContent>
|
| 56 |
+
</Select>
|
| 57 |
+
</div>
|
| 58 |
+
<div className="space-y-2">
|
| 59 |
+
<Label htmlFor="visualization">Algorithm</Label>
|
| 60 |
+
<Select value={visualization} onValueChange={(v) => handleUpdate("view", v)}>
|
| 61 |
+
<SelectTrigger id="visualization">
|
| 62 |
+
<SelectValue placeholder="Select algorithm" />
|
| 63 |
+
</SelectTrigger>
|
| 64 |
+
<SelectContent>
|
| 65 |
+
<SelectItem value="UMAP">UMAP</SelectItem>
|
| 66 |
+
<SelectItem value="t-SNE">t-SNE</SelectItem>
|
| 67 |
+
<SelectItem value="PCA">PCA</SelectItem>
|
| 68 |
+
</SelectContent>
|
| 69 |
+
</Select>
|
| 70 |
+
</div>
|
| 71 |
+
<div className="space-y-2">
|
| 72 |
+
<Label htmlFor="colorBy">Color Mapping</Label>
|
| 73 |
+
<Select value={colorBy} onValueChange={(v) => handleUpdate("colorBy", v)}>
|
| 74 |
+
<SelectTrigger id="colorBy">
|
| 75 |
+
<SelectValue placeholder="Select color metric" />
|
| 76 |
+
</SelectTrigger>
|
| 77 |
+
<SelectContent>
|
| 78 |
+
<SelectItem value="Activity">Binding Affinity</SelectItem>
|
| 79 |
+
<SelectItem value="MW">Molecular Weight</SelectItem>
|
| 80 |
+
<SelectItem value="LogP">LogP</SelectItem>
|
| 81 |
+
<SelectItem value="Cluster">Cluster ID</SelectItem>
|
| 82 |
+
</SelectContent>
|
| 83 |
+
</Select>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div className="pt-4">
|
| 87 |
+
<Button
|
| 88 |
+
variant="secondary"
|
| 89 |
+
className="w-full"
|
| 90 |
+
disabled={isPending}
|
| 91 |
+
onClick={() => handleUpdate("refresh", Date.now().toString())}
|
| 92 |
+
>
|
| 93 |
+
<RefreshCw className={`mr-2 h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
|
| 94 |
+
{isPending ? "Updating..." : "Regenerate View"}
|
| 95 |
+
</Button>
|
| 96 |
+
</div>
|
| 97 |
+
</CardContent>
|
| 98 |
+
</Card>
|
| 99 |
+
)
|
| 100 |
+
}
|
ui/app/dashboard/explorer/page.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Loader2 } from "lucide-react"
|
| 2 |
+
import { Suspense } from "react"
|
| 3 |
+
|
| 4 |
+
import { getExplorerPoints } from "@/lib/explorer-service"
|
| 5 |
+
|
| 6 |
+
import { ExplorerChart } from "./chart"
|
| 7 |
+
import { ExplorerControls } from "./components"
|
| 8 |
+
|
| 9 |
+
interface ExplorerPageProps {
|
| 10 |
+
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function pickParam(v: string | string[] | undefined): string | undefined {
|
| 14 |
+
if (Array.isArray(v)) return v[0]
|
| 15 |
+
return v
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default async function ExplorerPage({ searchParams }: ExplorerPageProps) {
|
| 19 |
+
// Await searchParams as required by Next.js 16/15
|
| 20 |
+
const params = await searchParams
|
| 21 |
+
|
| 22 |
+
const dataset = pickParam(params.dataset) || "DrugBank"
|
| 23 |
+
const view = pickParam(params.view) || "UMAP"
|
| 24 |
+
const colorBy = pickParam(params.colorBy) || "Activity"
|
| 25 |
+
|
| 26 |
+
const { points: data } = await getExplorerPoints(dataset, view, colorBy)
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<div className="container mx-auto p-6 space-y-6">
|
| 30 |
+
<div className="flex flex-col space-y-2">
|
| 31 |
+
<h1 className="text-3xl font-bold tracking-tight">Data Explorer</h1>
|
| 32 |
+
<p className="text-muted-foreground">
|
| 33 |
+
Visualize binding affinity landscapes and model predictions in 3D space using dimensionality reduction.
|
| 34 |
+
</p>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
| 38 |
+
<div className="lg:col-span-1">
|
| 39 |
+
<Suspense fallback={<div>Loading controls...</div>}>
|
| 40 |
+
<ExplorerControls />
|
| 41 |
+
</Suspense>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div className="lg:col-span-3">
|
| 45 |
+
<Suspense
|
| 46 |
+
key={`${dataset}-${view}-${colorBy}`}
|
| 47 |
+
fallback={
|
| 48 |
+
<div className="h-[500px] flex items-center justify-center border rounded-lg bg-muted/10">
|
| 49 |
+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
| 50 |
+
</div>
|
| 51 |
+
}
|
| 52 |
+
>
|
| 53 |
+
<ExplorerChart data={data} />
|
| 54 |
+
</Suspense>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
)
|
| 59 |
+
}
|
ui/app/dashboard/molecules-2d/_components/Smiles2DViewer.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { AlertCircle } from 'lucide-react';
|
| 4 |
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
| 5 |
+
import SmilesDrawer from 'smiles-drawer';
|
| 6 |
+
|
| 7 |
+
import { Card, CardContent } from '@/components/ui/card';
|
| 8 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 9 |
+
|
| 10 |
+
interface Smiles2DViewerProps {
|
| 11 |
+
smiles: string;
|
| 12 |
+
width?: number;
|
| 13 |
+
height?: number;
|
| 14 |
+
className?: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function Smiles2DViewer({
|
| 18 |
+
smiles,
|
| 19 |
+
width = 400,
|
| 20 |
+
height = 300,
|
| 21 |
+
className,
|
| 22 |
+
}: Smiles2DViewerProps) {
|
| 23 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 24 |
+
const canvasId = useId();
|
| 25 |
+
const [error, setError] = useState<string | null>(null);
|
| 26 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 27 |
+
|
| 28 |
+
const drawMolecule = useCallback(async () => {
|
| 29 |
+
if (!canvasRef.current || !smiles) {
|
| 30 |
+
setIsLoading(false);
|
| 31 |
+
return;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
setIsLoading(true);
|
| 35 |
+
setError(null);
|
| 36 |
+
|
| 37 |
+
try {
|
| 38 |
+
const drawer = new SmilesDrawer.SmiDrawer({
|
| 39 |
+
width,
|
| 40 |
+
height,
|
| 41 |
+
bondThickness: 1.5,
|
| 42 |
+
bondLength: 15,
|
| 43 |
+
shortBondLength: 0.85,
|
| 44 |
+
bondSpacing: 4,
|
| 45 |
+
atomVisualization: 'default',
|
| 46 |
+
isomeric: true,
|
| 47 |
+
debug: false,
|
| 48 |
+
terminalCarbons: false,
|
| 49 |
+
explicitHydrogens: false,
|
| 50 |
+
compactDrawing: true,
|
| 51 |
+
fontSizeLarge: 11,
|
| 52 |
+
fontSizeSmall: 8,
|
| 53 |
+
padding: 20,
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
try {
|
| 57 |
+
await new Promise<void>((resolve, reject) => {
|
| 58 |
+
(drawer as any).draw(
|
| 59 |
+
smiles,
|
| 60 |
+
`[id="${canvasId}"]`,
|
| 61 |
+
'light',
|
| 62 |
+
() => resolve(),
|
| 63 |
+
(drawError: Error) => reject(drawError)
|
| 64 |
+
);
|
| 65 |
+
});
|
| 66 |
+
} catch (drawError) {
|
| 67 |
+
throw drawError;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
setIsLoading(false);
|
| 71 |
+
} catch (err) {
|
| 72 |
+
console.error('SMILES drawing error:', err);
|
| 73 |
+
setError(
|
| 74 |
+
err instanceof Error
|
| 75 |
+
? `Invalid SMILES: ${err.message}`
|
| 76 |
+
: 'Failed to render molecule structure'
|
| 77 |
+
);
|
| 78 |
+
setIsLoading(false);
|
| 79 |
+
}
|
| 80 |
+
}, [smiles, width, height, canvasId]);
|
| 81 |
+
|
| 82 |
+
useEffect(() => {
|
| 83 |
+
drawMolecule();
|
| 84 |
+
}, [drawMolecule]);
|
| 85 |
+
|
| 86 |
+
if (error) {
|
| 87 |
+
return (
|
| 88 |
+
<Card className={`border-destructive bg-destructive/10 ${className}`}>
|
| 89 |
+
<CardContent className="flex items-center gap-3 p-4">
|
| 90 |
+
<AlertCircle className="size-5 text-destructive" />
|
| 91 |
+
<div className="flex flex-col">
|
| 92 |
+
<span className="text-sm font-medium text-destructive">
|
| 93 |
+
Visualization Error
|
| 94 |
+
</span>
|
| 95 |
+
<span className="text-xs text-muted-foreground">{error}</span>
|
| 96 |
+
</div>
|
| 97 |
+
</CardContent>
|
| 98 |
+
</Card>
|
| 99 |
+
);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
return (
|
| 103 |
+
<div className={`relative ${className}`}>
|
| 104 |
+
{isLoading && (
|
| 105 |
+
<Skeleton
|
| 106 |
+
className="absolute inset-0"
|
| 107 |
+
style={{ width, height }}
|
| 108 |
+
/>
|
| 109 |
+
)}
|
| 110 |
+
<canvas
|
| 111 |
+
ref={canvasRef}
|
| 112 |
+
id={canvasId}
|
| 113 |
+
width={width}
|
| 114 |
+
height={height}
|
| 115 |
+
className={`bg-background rounded-lg ${isLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-200`}
|
| 116 |
+
/>
|
| 117 |
+
</div>
|
| 118 |
+
);
|
| 119 |
+
}
|
ui/app/dashboard/molecules-2d/page.tsx
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { Check, Copy, Search } from 'lucide-react';
|
| 4 |
+
import dynamic from 'next/dynamic';
|
| 5 |
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
| 6 |
+
|
| 7 |
+
import { Badge } from '@/components/ui/badge';
|
| 8 |
+
import { Button } from '@/components/ui/button';
|
| 9 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
| 10 |
+
import { Input } from '@/components/ui/input';
|
| 11 |
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
| 12 |
+
import { Separator } from '@/components/ui/separator';
|
| 13 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 14 |
+
import { getMolecules } from '@/lib/visualization-api';
|
| 15 |
+
import type { Molecule } from '@/lib/visualization-types';
|
| 16 |
+
|
| 17 |
+
// Dynamic import of the 2D viewer to prevent SSR issues
|
| 18 |
+
const Smiles2DViewer = dynamic(
|
| 19 |
+
() =>
|
| 20 |
+
import('./_components/Smiles2DViewer').then((mod) => mod.Smiles2DViewer),
|
| 21 |
+
{
|
| 22 |
+
ssr: false,
|
| 23 |
+
loading: () => <Skeleton className="size-[400px]" />,
|
| 24 |
+
}
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
export default function Molecules2DPage() {
|
| 28 |
+
const [molecules, setMolecules] = useState<Molecule[]>([]);
|
| 29 |
+
const [selectedMolecule, setSelectedMolecule] = useState<Molecule | null>(null);
|
| 30 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 31 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 32 |
+
const [error, setError] = useState<string | null>(null);
|
| 33 |
+
const [copied, setCopied] = useState(false);
|
| 34 |
+
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
const loadMolecules = async () => {
|
| 37 |
+
try {
|
| 38 |
+
setIsLoading(true);
|
| 39 |
+
const data = await getMolecules();
|
| 40 |
+
setMolecules(data);
|
| 41 |
+
if (data.length > 0) {
|
| 42 |
+
setSelectedMolecule(data[0]);
|
| 43 |
+
}
|
| 44 |
+
} catch (err) {
|
| 45 |
+
setError(
|
| 46 |
+
err instanceof Error ? err.message : 'Failed to load molecules'
|
| 47 |
+
);
|
| 48 |
+
} finally {
|
| 49 |
+
setIsLoading(false);
|
| 50 |
+
}
|
| 51 |
+
};
|
| 52 |
+
loadMolecules();
|
| 53 |
+
}, []);
|
| 54 |
+
|
| 55 |
+
const filteredMolecules = useMemo(() => {
|
| 56 |
+
if (!searchQuery.trim()) return molecules;
|
| 57 |
+
const query = searchQuery.toLowerCase();
|
| 58 |
+
return molecules.filter(
|
| 59 |
+
(m) =>
|
| 60 |
+
m.name.toLowerCase().includes(query) ||
|
| 61 |
+
m.smiles.toLowerCase().includes(query)
|
| 62 |
+
);
|
| 63 |
+
}, [molecules, searchQuery]);
|
| 64 |
+
|
| 65 |
+
const handleCopySmiles = useCallback(async () => {
|
| 66 |
+
if (!selectedMolecule) return;
|
| 67 |
+
try {
|
| 68 |
+
await navigator.clipboard.writeText(selectedMolecule.smiles);
|
| 69 |
+
setCopied(true);
|
| 70 |
+
setTimeout(() => setCopied(false), 2000);
|
| 71 |
+
} catch {
|
| 72 |
+
// Fallback for older browsers
|
| 73 |
+
const textArea = document.createElement('textarea');
|
| 74 |
+
textArea.value = selectedMolecule.smiles;
|
| 75 |
+
document.body.appendChild(textArea);
|
| 76 |
+
textArea.select();
|
| 77 |
+
document.execCommand('copy');
|
| 78 |
+
document.body.removeChild(textArea);
|
| 79 |
+
setCopied(true);
|
| 80 |
+
setTimeout(() => setCopied(false), 2000);
|
| 81 |
+
}
|
| 82 |
+
}, [selectedMolecule]);
|
| 83 |
+
|
| 84 |
+
if (error) {
|
| 85 |
+
return (
|
| 86 |
+
<div className="flex h-full items-center justify-center">
|
| 87 |
+
<Card className="max-w-md">
|
| 88 |
+
<CardHeader>
|
| 89 |
+
<CardTitle className="text-destructive">Error</CardTitle>
|
| 90 |
+
<CardDescription>{error}</CardDescription>
|
| 91 |
+
</CardHeader>
|
| 92 |
+
</Card>
|
| 93 |
+
</div>
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
return (
|
| 98 |
+
<div className="flex h-full gap-4 p-4">
|
| 99 |
+
{/* Left Panel - Molecule List */}
|
| 100 |
+
<Card className="w-80 shrink-0">
|
| 101 |
+
<CardHeader className="pb-3">
|
| 102 |
+
<CardTitle className="text-lg">Molecules</CardTitle>
|
| 103 |
+
<CardDescription>Select a molecule to visualize</CardDescription>
|
| 104 |
+
</CardHeader>
|
| 105 |
+
<CardContent className="pb-3">
|
| 106 |
+
<div className="relative">
|
| 107 |
+
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
| 108 |
+
<Input
|
| 109 |
+
placeholder="Search molecules..."
|
| 110 |
+
className="pl-8"
|
| 111 |
+
value={searchQuery}
|
| 112 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 113 |
+
/>
|
| 114 |
+
</div>
|
| 115 |
+
</CardContent>
|
| 116 |
+
<Separator />
|
| 117 |
+
<ScrollArea className="h-[calc(100vh-280px)]">
|
| 118 |
+
<div className="p-2">
|
| 119 |
+
{isLoading ? (
|
| 120 |
+
<div className="space-y-2">
|
| 121 |
+
{[1, 2, 3].map((i) => (
|
| 122 |
+
<Skeleton key={i} className="h-16 w-full" />
|
| 123 |
+
))}
|
| 124 |
+
</div>
|
| 125 |
+
) : filteredMolecules.length === 0 ? (
|
| 126 |
+
<p className="p-4 text-center text-sm text-muted-foreground">
|
| 127 |
+
No molecules found
|
| 128 |
+
</p>
|
| 129 |
+
) : (
|
| 130 |
+
<div className="space-y-1">
|
| 131 |
+
{filteredMolecules.map((molecule) => (
|
| 132 |
+
<button
|
| 133 |
+
key={molecule.id}
|
| 134 |
+
onClick={() => setSelectedMolecule(molecule)}
|
| 135 |
+
className={`w-full rounded-lg p-3 text-left transition-colors hover:bg-accent ${
|
| 136 |
+
selectedMolecule?.id === molecule.id
|
| 137 |
+
? 'bg-accent'
|
| 138 |
+
: 'bg-transparent'
|
| 139 |
+
}`}
|
| 140 |
+
>
|
| 141 |
+
<div className="font-medium">{molecule.name}</div>
|
| 142 |
+
<div className="truncate text-xs text-muted-foreground">
|
| 143 |
+
{molecule.smiles}
|
| 144 |
+
</div>
|
| 145 |
+
</button>
|
| 146 |
+
))}
|
| 147 |
+
</div>
|
| 148 |
+
)}
|
| 149 |
+
</div>
|
| 150 |
+
</ScrollArea>
|
| 151 |
+
</Card>
|
| 152 |
+
|
| 153 |
+
{/* Right Panel - Visualization */}
|
| 154 |
+
<div className="flex flex-1 flex-col gap-6">
|
| 155 |
+
{selectedMolecule ? (
|
| 156 |
+
<>
|
| 157 |
+
{/* Molecule Info Card */}
|
| 158 |
+
<Card>
|
| 159 |
+
<CardHeader className="pb-3">
|
| 160 |
+
<div className="flex items-start justify-between">
|
| 161 |
+
<div>
|
| 162 |
+
<CardTitle>{selectedMolecule.name}</CardTitle>
|
| 163 |
+
<CardDescription>
|
| 164 |
+
{selectedMolecule.description}
|
| 165 |
+
</CardDescription>
|
| 166 |
+
</div>
|
| 167 |
+
<Badge variant="outline">
|
| 168 |
+
PubChem: {selectedMolecule.pubchemCid}
|
| 169 |
+
</Badge>
|
| 170 |
+
</div>
|
| 171 |
+
</CardHeader>
|
| 172 |
+
<CardContent>
|
| 173 |
+
<div className="flex items-center gap-2">
|
| 174 |
+
<code className="flex-1 truncate rounded bg-muted px-2 py-1 text-sm">
|
| 175 |
+
{selectedMolecule.smiles}
|
| 176 |
+
</code>
|
| 177 |
+
<Button
|
| 178 |
+
variant="outline"
|
| 179 |
+
size="sm"
|
| 180 |
+
onClick={handleCopySmiles}
|
| 181 |
+
className="shrink-0"
|
| 182 |
+
>
|
| 183 |
+
{copied ? (
|
| 184 |
+
<>
|
| 185 |
+
<Check className="mr-1 size-4" />
|
| 186 |
+
Copied
|
| 187 |
+
</>
|
| 188 |
+
) : (
|
| 189 |
+
<>
|
| 190 |
+
<Copy className="mr-1 size-4" />
|
| 191 |
+
Copy SMILES
|
| 192 |
+
</>
|
| 193 |
+
)}
|
| 194 |
+
</Button>
|
| 195 |
+
</div>
|
| 196 |
+
</CardContent>
|
| 197 |
+
</Card>
|
| 198 |
+
|
| 199 |
+
{/* 2D Structure Card */}
|
| 200 |
+
<Card className="flex-1">
|
| 201 |
+
<CardHeader>
|
| 202 |
+
<CardTitle className="text-lg">2D Structure</CardTitle>
|
| 203 |
+
</CardHeader>
|
| 204 |
+
<CardContent className="flex items-center justify-center">
|
| 205 |
+
<Smiles2DViewer
|
| 206 |
+
smiles={selectedMolecule.smiles}
|
| 207 |
+
width={500}
|
| 208 |
+
height={400}
|
| 209 |
+
/>
|
| 210 |
+
</CardContent>
|
| 211 |
+
</Card>
|
| 212 |
+
</>
|
| 213 |
+
) : (
|
| 214 |
+
<div className="flex flex-1 items-center justify-center">
|
| 215 |
+
<p className="text-muted-foreground">
|
| 216 |
+
Select a molecule to view its structure
|
| 217 |
+
</p>
|
| 218 |
+
</div>
|
| 219 |
+
)}
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
);
|
| 223 |
+
}
|
ui/app/dashboard/molecules-3d/_components/Molecule3DViewer.tsx
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { AlertCircle, RotateCcw } from 'lucide-react';
|
| 4 |
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
| 5 |
+
|
| 6 |
+
import { Button } from '@/components/ui/button';
|
| 7 |
+
import { Card, CardContent } from '@/components/ui/card';
|
| 8 |
+
import {
|
| 9 |
+
Select,
|
| 10 |
+
SelectContent,
|
| 11 |
+
SelectItem,
|
| 12 |
+
SelectTrigger,
|
| 13 |
+
SelectValue,
|
| 14 |
+
} from '@/components/ui/select';
|
| 15 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 16 |
+
import type { MoleculeRepresentation } from '@/lib/visualization-types';
|
| 17 |
+
|
| 18 |
+
interface Molecule3DViewerProps {
|
| 19 |
+
sdfData: string;
|
| 20 |
+
width?: number;
|
| 21 |
+
height?: number;
|
| 22 |
+
className?: string;
|
| 23 |
+
initialRepresentation?: MoleculeRepresentation;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 27 |
+
type $3Dmol = any;
|
| 28 |
+
|
| 29 |
+
export function Molecule3DViewer({
|
| 30 |
+
sdfData,
|
| 31 |
+
width = 400,
|
| 32 |
+
height = 400,
|
| 33 |
+
className,
|
| 34 |
+
initialRepresentation = 'stick',
|
| 35 |
+
}: Molecule3DViewerProps) {
|
| 36 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 37 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 38 |
+
const viewerRef = useRef<any>(null);
|
| 39 |
+
const [error, setError] = useState<string | null>(null);
|
| 40 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 41 |
+
const [representation, setRepresentation] =
|
| 42 |
+
useState<MoleculeRepresentation>(initialRepresentation);
|
| 43 |
+
const [$3Dmol, set$3Dmol] = useState<$3Dmol | null>(null);
|
| 44 |
+
|
| 45 |
+
// Load 3Dmol.js dynamically
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
const load3Dmol = async () => {
|
| 48 |
+
try {
|
| 49 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 50 |
+
const module = await import('3dmol') as any;
|
| 51 |
+
set$3Dmol(module);
|
| 52 |
+
} catch (err) {
|
| 53 |
+
console.error('Failed to load 3Dmol:', err);
|
| 54 |
+
setError('Failed to load 3D visualization library');
|
| 55 |
+
setIsLoading(false);
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
load3Dmol();
|
| 59 |
+
}, []);
|
| 60 |
+
|
| 61 |
+
const getStyleForRepresentation = useCallback(
|
| 62 |
+
(rep: MoleculeRepresentation) => {
|
| 63 |
+
switch (rep) {
|
| 64 |
+
case 'stick':
|
| 65 |
+
return { stick: { radius: 0.15 } };
|
| 66 |
+
case 'sphere':
|
| 67 |
+
return { sphere: { scale: 0.3 } };
|
| 68 |
+
case 'line':
|
| 69 |
+
return { line: { linewidth: 2 } };
|
| 70 |
+
case 'cartoon':
|
| 71 |
+
return { cartoon: {} };
|
| 72 |
+
default:
|
| 73 |
+
return { stick: { radius: 0.15 } };
|
| 74 |
+
}
|
| 75 |
+
},
|
| 76 |
+
[]
|
| 77 |
+
);
|
| 78 |
+
|
| 79 |
+
const initViewer = useCallback(() => {
|
| 80 |
+
if (!containerRef.current || !$3Dmol || !sdfData) return;
|
| 81 |
+
|
| 82 |
+
setIsLoading(true);
|
| 83 |
+
setError(null);
|
| 84 |
+
|
| 85 |
+
try {
|
| 86 |
+
// Clean up existing viewer
|
| 87 |
+
if (viewerRef.current) {
|
| 88 |
+
viewerRef.current.removeAllModels();
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// Create viewer - use white for light mode, dark for dark mode
|
| 92 |
+
// 3Dmol doesn't support 'transparent', use hex color instead
|
| 93 |
+
const viewer = $3Dmol.createViewer(containerRef.current, {
|
| 94 |
+
backgroundColor: 0xffffff,
|
| 95 |
+
});
|
| 96 |
+
viewerRef.current = viewer;
|
| 97 |
+
|
| 98 |
+
// Add molecule
|
| 99 |
+
viewer.addModel(sdfData, 'sdf');
|
| 100 |
+
viewer.setStyle({}, getStyleForRepresentation(representation));
|
| 101 |
+
viewer.zoomTo();
|
| 102 |
+
viewer.render();
|
| 103 |
+
|
| 104 |
+
setIsLoading(false);
|
| 105 |
+
} catch (err) {
|
| 106 |
+
console.error('3D viewer error:', err);
|
| 107 |
+
setError(
|
| 108 |
+
err instanceof Error
|
| 109 |
+
? `Visualization error: ${err.message}`
|
| 110 |
+
: 'Failed to render 3D structure'
|
| 111 |
+
);
|
| 112 |
+
setIsLoading(false);
|
| 113 |
+
}
|
| 114 |
+
}, [$3Dmol, sdfData, representation, getStyleForRepresentation]);
|
| 115 |
+
|
| 116 |
+
useEffect(() => {
|
| 117 |
+
initViewer();
|
| 118 |
+
}, [$3Dmol, sdfData, initViewer]);
|
| 119 |
+
|
| 120 |
+
// Update representation
|
| 121 |
+
useEffect(() => {
|
| 122 |
+
if (!viewerRef.current) return;
|
| 123 |
+
try {
|
| 124 |
+
viewerRef.current.setStyle({}, getStyleForRepresentation(representation));
|
| 125 |
+
viewerRef.current.render();
|
| 126 |
+
} catch (err) {
|
| 127 |
+
console.error('Style update error:', err);
|
| 128 |
+
}
|
| 129 |
+
}, [representation, getStyleForRepresentation]);
|
| 130 |
+
|
| 131 |
+
const handleResetCamera = useCallback(() => {
|
| 132 |
+
if (!viewerRef.current) return;
|
| 133 |
+
viewerRef.current.zoomTo();
|
| 134 |
+
viewerRef.current.render();
|
| 135 |
+
}, []);
|
| 136 |
+
|
| 137 |
+
if (error) {
|
| 138 |
+
return (
|
| 139 |
+
<Card className={`border-destructive bg-destructive/10 ${className}`}>
|
| 140 |
+
<CardContent className="flex items-center gap-3 p-4">
|
| 141 |
+
<AlertCircle className="size-5 text-destructive" />
|
| 142 |
+
<div className="flex flex-col">
|
| 143 |
+
<span className="text-sm font-medium text-destructive">
|
| 144 |
+
3D Visualization Error
|
| 145 |
+
</span>
|
| 146 |
+
<span className="text-xs text-muted-foreground">{error}</span>
|
| 147 |
+
</div>
|
| 148 |
+
</CardContent>
|
| 149 |
+
</Card>
|
| 150 |
+
);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
return (
|
| 154 |
+
<div className={`flex flex-col gap-3 ${className}`}>
|
| 155 |
+
<div className="flex items-center gap-2">
|
| 156 |
+
<Select
|
| 157 |
+
value={representation}
|
| 158 |
+
onValueChange={(value) =>
|
| 159 |
+
setRepresentation(value as MoleculeRepresentation)
|
| 160 |
+
}
|
| 161 |
+
>
|
| 162 |
+
<SelectTrigger className="w-32">
|
| 163 |
+
<SelectValue placeholder="Style" />
|
| 164 |
+
</SelectTrigger>
|
| 165 |
+
<SelectContent>
|
| 166 |
+
<SelectItem value="stick">Stick</SelectItem>
|
| 167 |
+
<SelectItem value="sphere">Sphere</SelectItem>
|
| 168 |
+
<SelectItem value="line">Line</SelectItem>
|
| 169 |
+
</SelectContent>
|
| 170 |
+
</Select>
|
| 171 |
+
<Button variant="outline" size="sm" onClick={handleResetCamera}>
|
| 172 |
+
<RotateCcw className="mr-1 size-4" />
|
| 173 |
+
Reset
|
| 174 |
+
</Button>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<div className="relative">
|
| 178 |
+
{isLoading && (
|
| 179 |
+
<Skeleton
|
| 180 |
+
className="absolute inset-0 z-10"
|
| 181 |
+
style={{ width, height }}
|
| 182 |
+
/>
|
| 183 |
+
)}
|
| 184 |
+
<div
|
| 185 |
+
ref={containerRef}
|
| 186 |
+
style={{ width, height }}
|
| 187 |
+
className={`rounded-lg border bg-background ${isLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-200`}
|
| 188 |
+
/>
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
);
|
| 192 |
+
}
|
ui/app/dashboard/molecules-3d/page.tsx
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { Check, Copy, Search } from 'lucide-react';
|
| 4 |
+
import dynamic from 'next/dynamic';
|
| 5 |
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
| 6 |
+
|
| 7 |
+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
| 8 |
+
import { Badge } from '@/components/ui/badge';
|
| 9 |
+
import { Button } from '@/components/ui/button';
|
| 10 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
| 11 |
+
import { Input } from '@/components/ui/input';
|
| 12 |
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
| 13 |
+
import { Separator } from '@/components/ui/separator';
|
| 14 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 15 |
+
import { getMolecules, getMoleculeSDF } from '@/lib/visualization-api';
|
| 16 |
+
import type { Molecule } from '@/lib/visualization-types';
|
| 17 |
+
|
| 18 |
+
// Dynamic import of the 3D viewer to prevent SSR issues
|
| 19 |
+
const Molecule3DViewer = dynamic(
|
| 20 |
+
() =>
|
| 21 |
+
import('./_components/Molecule3DViewer').then((mod) => mod.Molecule3DViewer),
|
| 22 |
+
{
|
| 23 |
+
ssr: false,
|
| 24 |
+
loading: () => <Skeleton className="size-[400px]" />,
|
| 25 |
+
}
|
| 26 |
+
);
|
| 27 |
+
|
| 28 |
+
export default function Molecules3DPage() {
|
| 29 |
+
const [molecules, setMolecules] = useState<Molecule[]>([]);
|
| 30 |
+
const [selectedMolecule, setSelectedMolecule] = useState<Molecule | null>(null);
|
| 31 |
+
const [sdfData, setSdfData] = useState<string | null>(null);
|
| 32 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 33 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 34 |
+
const [isSdfLoading, setIsSdfLoading] = useState(false);
|
| 35 |
+
const [error, setError] = useState<string | null>(null);
|
| 36 |
+
const [sdfError, setSdfError] = useState<string | null>(null);
|
| 37 |
+
const [copied, setCopied] = useState(false);
|
| 38 |
+
|
| 39 |
+
// Load molecules list
|
| 40 |
+
useEffect(() => {
|
| 41 |
+
const loadMolecules = async () => {
|
| 42 |
+
try {
|
| 43 |
+
setIsLoading(true);
|
| 44 |
+
const data = await getMolecules();
|
| 45 |
+
setMolecules(data);
|
| 46 |
+
if (data.length > 0) {
|
| 47 |
+
setSelectedMolecule(data[0]);
|
| 48 |
+
}
|
| 49 |
+
} catch (err) {
|
| 50 |
+
setError(
|
| 51 |
+
err instanceof Error ? err.message : 'Failed to load molecules'
|
| 52 |
+
);
|
| 53 |
+
} finally {
|
| 54 |
+
setIsLoading(false);
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
+
loadMolecules();
|
| 58 |
+
}, []);
|
| 59 |
+
|
| 60 |
+
// Load SDF when molecule changes
|
| 61 |
+
useEffect(() => {
|
| 62 |
+
if (!selectedMolecule) {
|
| 63 |
+
setSdfData(null);
|
| 64 |
+
return;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const loadSdf = async () => {
|
| 68 |
+
try {
|
| 69 |
+
setIsSdfLoading(true);
|
| 70 |
+
setSdfError(null);
|
| 71 |
+
const data = await getMoleculeSDF(selectedMolecule.id);
|
| 72 |
+
setSdfData(data);
|
| 73 |
+
} catch (err) {
|
| 74 |
+
setSdfError(
|
| 75 |
+
err instanceof Error ? err.message : 'Failed to load 3D structure'
|
| 76 |
+
);
|
| 77 |
+
setSdfData(null);
|
| 78 |
+
} finally {
|
| 79 |
+
setIsSdfLoading(false);
|
| 80 |
+
}
|
| 81 |
+
};
|
| 82 |
+
loadSdf();
|
| 83 |
+
}, [selectedMolecule]);
|
| 84 |
+
|
| 85 |
+
const filteredMolecules = useMemo(() => {
|
| 86 |
+
if (!searchQuery.trim()) return molecules;
|
| 87 |
+
const query = searchQuery.toLowerCase();
|
| 88 |
+
return molecules.filter(
|
| 89 |
+
(m) =>
|
| 90 |
+
m.name.toLowerCase().includes(query) ||
|
| 91 |
+
m.smiles.toLowerCase().includes(query)
|
| 92 |
+
);
|
| 93 |
+
}, [molecules, searchQuery]);
|
| 94 |
+
|
| 95 |
+
const handleCopySmiles = useCallback(async () => {
|
| 96 |
+
if (!selectedMolecule) return;
|
| 97 |
+
try {
|
| 98 |
+
await navigator.clipboard.writeText(selectedMolecule.smiles);
|
| 99 |
+
setCopied(true);
|
| 100 |
+
setTimeout(() => setCopied(false), 2000);
|
| 101 |
+
} catch {
|
| 102 |
+
const textArea = document.createElement('textarea');
|
| 103 |
+
textArea.value = selectedMolecule.smiles;
|
| 104 |
+
document.body.appendChild(textArea);
|
| 105 |
+
textArea.select();
|
| 106 |
+
document.execCommand('copy');
|
| 107 |
+
document.body.removeChild(textArea);
|
| 108 |
+
setCopied(true);
|
| 109 |
+
setTimeout(() => setCopied(false), 2000);
|
| 110 |
+
}
|
| 111 |
+
}, [selectedMolecule]);
|
| 112 |
+
|
| 113 |
+
if (error) {
|
| 114 |
+
return (
|
| 115 |
+
<div className="flex h-full items-center justify-center">
|
| 116 |
+
<Card className="max-w-md">
|
| 117 |
+
<CardHeader>
|
| 118 |
+
<CardTitle className="text-destructive">Error</CardTitle>
|
| 119 |
+
<CardDescription>{error}</CardDescription>
|
| 120 |
+
</CardHeader>
|
| 121 |
+
</Card>
|
| 122 |
+
</div>
|
| 123 |
+
);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
return (
|
| 127 |
+
<div className="flex h-full gap-4 p-4">
|
| 128 |
+
{/* Left Panel - Molecule List */}
|
| 129 |
+
<Card className="w-80 shrink-0">
|
| 130 |
+
<CardHeader className="pb-3">
|
| 131 |
+
<CardTitle className="text-lg">Molecules</CardTitle>
|
| 132 |
+
<CardDescription>Select a molecule for 3D view</CardDescription>
|
| 133 |
+
</CardHeader>
|
| 134 |
+
<CardContent className="pb-3">
|
| 135 |
+
<div className="relative">
|
| 136 |
+
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
| 137 |
+
<Input
|
| 138 |
+
placeholder="Search molecules..."
|
| 139 |
+
className="pl-8"
|
| 140 |
+
value={searchQuery}
|
| 141 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 142 |
+
/>
|
| 143 |
+
</div>
|
| 144 |
+
</CardContent>
|
| 145 |
+
<Separator />
|
| 146 |
+
<ScrollArea className="h-[calc(100vh-280px)]">
|
| 147 |
+
<div className="p-2">
|
| 148 |
+
{isLoading ? (
|
| 149 |
+
<div className="space-y-2">
|
| 150 |
+
{[1, 2, 3].map((i) => (
|
| 151 |
+
<Skeleton key={i} className="h-16 w-full" />
|
| 152 |
+
))}
|
| 153 |
+
</div>
|
| 154 |
+
) : filteredMolecules.length === 0 ? (
|
| 155 |
+
<p className="p-4 text-center text-sm text-muted-foreground">
|
| 156 |
+
No molecules found
|
| 157 |
+
</p>
|
| 158 |
+
) : (
|
| 159 |
+
<div className="space-y-1">
|
| 160 |
+
{filteredMolecules.map((molecule) => (
|
| 161 |
+
<button
|
| 162 |
+
key={molecule.id}
|
| 163 |
+
onClick={() => setSelectedMolecule(molecule)}
|
| 164 |
+
className={`w-full rounded-lg p-3 text-left transition-colors hover:bg-accent ${
|
| 165 |
+
selectedMolecule?.id === molecule.id
|
| 166 |
+
? 'bg-accent'
|
| 167 |
+
: 'bg-transparent'
|
| 168 |
+
}`}
|
| 169 |
+
>
|
| 170 |
+
<div className="font-medium">{molecule.name}</div>
|
| 171 |
+
<div className="truncate text-xs text-muted-foreground">
|
| 172 |
+
{molecule.smiles}
|
| 173 |
+
</div>
|
| 174 |
+
</button>
|
| 175 |
+
))}
|
| 176 |
+
</div>
|
| 177 |
+
)}
|
| 178 |
+
</div>
|
| 179 |
+
</ScrollArea>
|
| 180 |
+
</Card>
|
| 181 |
+
|
| 182 |
+
{/* Right Panel - 3D Visualization */}
|
| 183 |
+
<div className="flex flex-1 flex-col gap-6">
|
| 184 |
+
{selectedMolecule ? (
|
| 185 |
+
<>
|
| 186 |
+
{/* Molecule Info Card */}
|
| 187 |
+
<Card>
|
| 188 |
+
<CardHeader className="pb-3">
|
| 189 |
+
<div className="flex items-start justify-between">
|
| 190 |
+
<div>
|
| 191 |
+
<CardTitle>{selectedMolecule.name}</CardTitle>
|
| 192 |
+
<CardDescription>
|
| 193 |
+
{selectedMolecule.description}
|
| 194 |
+
</CardDescription>
|
| 195 |
+
</div>
|
| 196 |
+
<Badge variant="outline">
|
| 197 |
+
PubChem: {selectedMolecule.pubchemCid}
|
| 198 |
+
</Badge>
|
| 199 |
+
</div>
|
| 200 |
+
</CardHeader>
|
| 201 |
+
<CardContent>
|
| 202 |
+
<div className="flex items-center gap-2">
|
| 203 |
+
<code className="flex-1 truncate rounded bg-muted px-2 py-1 text-sm">
|
| 204 |
+
{selectedMolecule.smiles}
|
| 205 |
+
</code>
|
| 206 |
+
<Button
|
| 207 |
+
variant="outline"
|
| 208 |
+
size="sm"
|
| 209 |
+
onClick={handleCopySmiles}
|
| 210 |
+
className="shrink-0"
|
| 211 |
+
>
|
| 212 |
+
{copied ? (
|
| 213 |
+
<>
|
| 214 |
+
<Check className="mr-1 size-4" />
|
| 215 |
+
Copied
|
| 216 |
+
</>
|
| 217 |
+
) : (
|
| 218 |
+
<>
|
| 219 |
+
<Copy className="mr-1 size-4" />
|
| 220 |
+
Copy SMILES
|
| 221 |
+
</>
|
| 222 |
+
)}
|
| 223 |
+
</Button>
|
| 224 |
+
</div>
|
| 225 |
+
</CardContent>
|
| 226 |
+
</Card>
|
| 227 |
+
|
| 228 |
+
{/* 3D Structure Card */}
|
| 229 |
+
<Card className="flex-1">
|
| 230 |
+
<CardHeader>
|
| 231 |
+
<CardTitle className="text-lg">3D Structure</CardTitle>
|
| 232 |
+
<CardDescription>
|
| 233 |
+
Rotate: click + drag • Zoom: scroll • Pan: right-click + drag
|
| 234 |
+
</CardDescription>
|
| 235 |
+
</CardHeader>
|
| 236 |
+
<CardContent className="flex items-center justify-center">
|
| 237 |
+
{isSdfLoading ? (
|
| 238 |
+
<Skeleton className="size-[400px]" />
|
| 239 |
+
) : sdfError ? (
|
| 240 |
+
<Alert variant="destructive" className="max-w-md">
|
| 241 |
+
<AlertTitle>Failed to load 3D structure</AlertTitle>
|
| 242 |
+
<AlertDescription>{sdfError}</AlertDescription>
|
| 243 |
+
</Alert>
|
| 244 |
+
) : sdfData ? (
|
| 245 |
+
<Molecule3DViewer sdfData={sdfData} width={500} height={400} />
|
| 246 |
+
) : (
|
| 247 |
+
<p className="text-muted-foreground">
|
| 248 |
+
No 3D structure available
|
| 249 |
+
</p>
|
| 250 |
+
)}
|
| 251 |
+
</CardContent>
|
| 252 |
+
</Card>
|
| 253 |
+
</>
|
| 254 |
+
) : (
|
| 255 |
+
<div className="flex flex-1 items-center justify-center">
|
| 256 |
+
<p className="text-muted-foreground">
|
| 257 |
+
Select a molecule to view its 3D structure
|
| 258 |
+
</p>
|
| 259 |
+
</div>
|
| 260 |
+
)}
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
);
|
| 264 |
+
}
|
ui/app/dashboard/page.tsx
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { ArrowUp, Database, FileText, Search, Sparkles, Zap, Beaker, Dna, BookOpen } from "lucide-react"
|
| 4 |
+
import Link from "next/link"
|
| 5 |
+
|
| 6 |
+
import { Badge } from "@/components/ui/badge"
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import { Card, CardContent } from "@/components/ui/card"
|
| 9 |
+
import { Separator } from "@/components/ui/separator"
|
| 10 |
+
|
| 11 |
+
export default function DashboardHome() {
|
| 12 |
+
return (
|
| 13 |
+
<div className="space-y-8 animate-in fade-in duration-500 p-8">
|
| 14 |
+
{/* Hero Section */}
|
| 15 |
+
<div className="flex flex-col lg:flex-row gap-6">
|
| 16 |
+
<div className="flex-1 rounded-2xl bg-gradient-to-br from-primary/10 via-background to-background p-8 border">
|
| 17 |
+
<Badge variant="secondary" className="mb-4">New • BioFlow 2.0</Badge>
|
| 18 |
+
<h1 className="text-4xl font-bold tracking-tight mb-4">AI-Powered Drug Discovery</h1>
|
| 19 |
+
<p className="text-lg text-muted-foreground mb-6 max-w-xl">
|
| 20 |
+
Run discovery pipelines, predict binding, and surface evidence in one streamlined workspace.
|
| 21 |
+
</p>
|
| 22 |
+
<div className="flex gap-2 mb-6">
|
| 23 |
+
<Badge variant="outline" className="bg-primary/5 border-primary/20 text-primary">Model-aware search</Badge>
|
| 24 |
+
<Badge variant="outline" className="bg-green-500/10 border-green-500/20 text-green-700 dark:text-green-400">Evidence-linked</Badge>
|
| 25 |
+
<Badge variant="outline" className="bg-amber-500/10 border-amber-500/20 text-amber-700 dark:text-amber-400">Fast iteration</Badge>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div className="flex gap-4">
|
| 29 |
+
<Link href="/dashboard/discovery">
|
| 30 |
+
<Button size="lg" className="font-semibold">
|
| 31 |
+
Start Discovery
|
| 32 |
+
</Button>
|
| 33 |
+
</Link>
|
| 34 |
+
<Link href="/dashboard/explorer">
|
| 35 |
+
<Button size="lg" variant="outline">
|
| 36 |
+
Explore Data
|
| 37 |
+
</Button>
|
| 38 |
+
</Link>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div className="lg:w-[350px]">
|
| 43 |
+
<Card className="h-full">
|
| 44 |
+
<CardContent className="p-6 flex flex-col justify-between h-full">
|
| 45 |
+
<div>
|
| 46 |
+
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">Today</div>
|
| 47 |
+
<div className="text-4xl font-bold mb-2">156 Discoveries</div>
|
| 48 |
+
<div className="text-sm text-green-600 font-medium flex items-center gap-1">
|
| 49 |
+
<ArrowUp className="h-4 w-4" />
|
| 50 |
+
+12% vs last week
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<Separator className="my-4" />
|
| 55 |
+
|
| 56 |
+
<div className="space-y-2">
|
| 57 |
+
<div className="flex items-center justify-between text-sm">
|
| 58 |
+
<span className="flex items-center gap-2">
|
| 59 |
+
<span className="h-2 w-2 rounded-full bg-primary"></span>
|
| 60 |
+
Discovery
|
| 61 |
+
</span>
|
| 62 |
+
<span className="font-mono font-medium">64</span>
|
| 63 |
+
</div>
|
| 64 |
+
<div className="flex items-center justify-between text-sm">
|
| 65 |
+
<span className="flex items-center gap-2">
|
| 66 |
+
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
| 67 |
+
Prediction
|
| 68 |
+
</span>
|
| 69 |
+
<span className="font-mono font-medium">42</span>
|
| 70 |
+
</div>
|
| 71 |
+
<div className="flex items-center justify-between text-sm">
|
| 72 |
+
<span className="flex items-center gap-2">
|
| 73 |
+
<span className="h-2 w-2 rounded-full bg-amber-500"></span>
|
| 74 |
+
Evidence
|
| 75 |
+
</span>
|
| 76 |
+
<span className="font-mono font-medium">50</span>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
</CardContent>
|
| 80 |
+
</Card>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
{/* Metrics Row */}
|
| 85 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 86 |
+
{[
|
| 87 |
+
{ label: "Molecules", value: "12.5M", icon: <Beaker className="h-5 w-5 text-blue-500" />, change: "+2.3%", color: "text-blue-500" },
|
| 88 |
+
{ label: "Proteins", value: "847K", icon: <Dna className="h-5 w-5 text-cyan-500" />, change: "+1.8%", color: "text-cyan-500" },
|
| 89 |
+
{ label: "Papers", value: "1.2M", icon: <BookOpen className="h-5 w-5 text-emerald-500" />, change: "+5.2%", color: "text-emerald-500" },
|
| 90 |
+
{ label: "Discoveries", value: "156", icon: <Sparkles className="h-5 w-5 text-amber-500" />, change: "+12%", color: "text-amber-500" }
|
| 91 |
+
].map((metric, i) => (
|
| 92 |
+
<Card key={i}>
|
| 93 |
+
<CardContent className="p-6">
|
| 94 |
+
<div className="flex justify-between items-start mb-2">
|
| 95 |
+
<div className="text-sm font-medium text-muted-foreground">{metric.label}</div>
|
| 96 |
+
<div className="text-lg">{metric.icon}</div>
|
| 97 |
+
</div>
|
| 98 |
+
<div className="text-2xl font-bold mb-1">{metric.value}</div>
|
| 99 |
+
<div className="text-xs font-medium flex items-center gap-1 text-green-500">
|
| 100 |
+
<ArrowUp className="h-3 w-3" />
|
| 101 |
+
{metric.change}
|
| 102 |
+
</div>
|
| 103 |
+
</CardContent>
|
| 104 |
+
</Card>
|
| 105 |
+
))}
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
{/* Quick Actions */}
|
| 109 |
+
<div className="pt-4">
|
| 110 |
+
<div className="flex items-center gap-2 mb-4">
|
| 111 |
+
<Zap className="h-5 w-5 text-amber-500" />
|
| 112 |
+
<h2 className="text-xl font-semibold">Quick Actions</h2>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
| 116 |
+
<Link href="/dashboard/molecules-2d" className="block">
|
| 117 |
+
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
| 118 |
+
<CardContent className="p-6 flex flex-col items-center text-center gap-3">
|
| 119 |
+
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
| 120 |
+
<Search className="h-5 w-5" />
|
| 121 |
+
</div>
|
| 122 |
+
<div>
|
| 123 |
+
<div className="font-semibold">Molecules 2D</div>
|
| 124 |
+
<div className="text-sm text-muted-foreground">View 2D structures</div>
|
| 125 |
+
</div>
|
| 126 |
+
</CardContent>
|
| 127 |
+
</Card>
|
| 128 |
+
</Link>
|
| 129 |
+
<Link href="/dashboard/molecules-3d" className="block">
|
| 130 |
+
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
| 131 |
+
<CardContent className="p-6 flex flex-col items-center text-center gap-3">
|
| 132 |
+
<div className="h-10 w-10 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-500">
|
| 133 |
+
<Database className="h-5 w-5" />
|
| 134 |
+
</div>
|
| 135 |
+
<div>
|
| 136 |
+
<div className="font-semibold">Molecules 3D</div>
|
| 137 |
+
<div className="text-sm text-muted-foreground">View 3D structures</div>
|
| 138 |
+
</div>
|
| 139 |
+
</CardContent>
|
| 140 |
+
</Card>
|
| 141 |
+
</Link>
|
| 142 |
+
<Link href="/dashboard/proteins-3d" className="block">
|
| 143 |
+
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
| 144 |
+
<CardContent className="p-6 flex flex-col items-center text-center gap-3">
|
| 145 |
+
<div className="h-10 w-10 rounded-full bg-purple-500/10 flex items-center justify-center text-purple-500">
|
| 146 |
+
<FileText className="h-5 w-5" />
|
| 147 |
+
</div>
|
| 148 |
+
<div>
|
| 149 |
+
<div className="font-semibold">Proteins 3D</div>
|
| 150 |
+
<div className="text-sm text-muted-foreground">View protein structures</div>
|
| 151 |
+
</div>
|
| 152 |
+
</CardContent>
|
| 153 |
+
</Card>
|
| 154 |
+
</Link>
|
| 155 |
+
<Link href="/dashboard/discovery" className="block">
|
| 156 |
+
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
| 157 |
+
<CardContent className="p-6 flex flex-col items-center text-center gap-3">
|
| 158 |
+
<div className="h-10 w-10 rounded-full bg-slate-500/10 flex items-center justify-center text-slate-500">
|
| 159 |
+
<Sparkles className="h-5 w-5" />
|
| 160 |
+
</div>
|
| 161 |
+
<div>
|
| 162 |
+
<div className="font-semibold">Discovery</div>
|
| 163 |
+
<div className="text-sm text-muted-foreground">Run predictions</div>
|
| 164 |
+
</div>
|
| 165 |
+
</CardContent>
|
| 166 |
+
</Card>
|
| 167 |
+
</Link>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
)
|
| 172 |
+
}
|
ui/app/dashboard/proteins-3d/_components/ProteinViewer.tsx
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { AlertCircle, RotateCcw } from 'lucide-react';
|
| 4 |
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
| 5 |
+
|
| 6 |
+
import { Button } from '@/components/ui/button';
|
| 7 |
+
import { Card, CardContent } from '@/components/ui/card';
|
| 8 |
+
import {
|
| 9 |
+
Select,
|
| 10 |
+
SelectContent,
|
| 11 |
+
SelectItem,
|
| 12 |
+
SelectTrigger,
|
| 13 |
+
SelectValue,
|
| 14 |
+
} from '@/components/ui/select';
|
| 15 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 16 |
+
import { getProteinPdbUrl } from '@/lib/visualization-api';
|
| 17 |
+
import type { ProteinRepresentation } from '@/lib/visualization-types';
|
| 18 |
+
|
| 19 |
+
interface ProteinViewerProps {
|
| 20 |
+
pdbId: string;
|
| 21 |
+
width?: number;
|
| 22 |
+
height?: number;
|
| 23 |
+
className?: string;
|
| 24 |
+
initialRepresentation?: ProteinRepresentation;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 28 |
+
type $3Dmol = any;
|
| 29 |
+
|
| 30 |
+
export function ProteinViewer({
|
| 31 |
+
pdbId,
|
| 32 |
+
width = 500,
|
| 33 |
+
height = 500,
|
| 34 |
+
className,
|
| 35 |
+
initialRepresentation = 'cartoon',
|
| 36 |
+
}: ProteinViewerProps) {
|
| 37 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 38 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 39 |
+
const viewerRef = useRef<any>(null);
|
| 40 |
+
const [error, setError] = useState<string | null>(null);
|
| 41 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 42 |
+
const [representation, setRepresentation] =
|
| 43 |
+
useState<ProteinRepresentation>(initialRepresentation);
|
| 44 |
+
const [$3Dmol, set$3Dmol] = useState<$3Dmol | null>(null);
|
| 45 |
+
const [pdbData, setPdbData] = useState<string | null>(null);
|
| 46 |
+
|
| 47 |
+
// Load 3Dmol.js dynamically
|
| 48 |
+
useEffect(() => {
|
| 49 |
+
const load3Dmol = async () => {
|
| 50 |
+
try {
|
| 51 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 52 |
+
const module = await import('3dmol') as any;
|
| 53 |
+
set$3Dmol(module);
|
| 54 |
+
} catch (err) {
|
| 55 |
+
console.error('Failed to load 3Dmol:', err);
|
| 56 |
+
setError('Failed to load 3D visualization library');
|
| 57 |
+
setIsLoading(false);
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
load3Dmol();
|
| 61 |
+
}, []);
|
| 62 |
+
|
| 63 |
+
// Fetch PDB data when pdbId changes
|
| 64 |
+
useEffect(() => {
|
| 65 |
+
if (!pdbId) return;
|
| 66 |
+
|
| 67 |
+
const fetchPdb = async () => {
|
| 68 |
+
try {
|
| 69 |
+
setIsLoading(true);
|
| 70 |
+
setError(null);
|
| 71 |
+
const pdbUrl = getProteinPdbUrl(pdbId);
|
| 72 |
+
const response = await fetch(pdbUrl);
|
| 73 |
+
|
| 74 |
+
if (!response.ok) {
|
| 75 |
+
throw new Error(`Failed to fetch PDB: ${response.statusText}`);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const data = await response.text();
|
| 79 |
+
setPdbData(data);
|
| 80 |
+
} catch (err) {
|
| 81 |
+
console.error('Failed to fetch PDB:', err);
|
| 82 |
+
setError(
|
| 83 |
+
err instanceof Error ? err.message : 'Failed to load PDB data'
|
| 84 |
+
);
|
| 85 |
+
setIsLoading(false);
|
| 86 |
+
}
|
| 87 |
+
};
|
| 88 |
+
fetchPdb();
|
| 89 |
+
}, [pdbId]);
|
| 90 |
+
|
| 91 |
+
const getStyleForRepresentation = useCallback(
|
| 92 |
+
(rep: ProteinRepresentation) => {
|
| 93 |
+
switch (rep) {
|
| 94 |
+
case 'cartoon':
|
| 95 |
+
return { cartoon: { color: 'spectrum' } };
|
| 96 |
+
case 'ball-and-stick':
|
| 97 |
+
return { stick: { radius: 0.15 }, sphere: { scale: 0.25 } };
|
| 98 |
+
case 'surface':
|
| 99 |
+
return { surface: {} }; // Surface handled via addSurface
|
| 100 |
+
case 'ribbon':
|
| 101 |
+
return { cartoon: { style: 'trace', color: 'spectrum' } };
|
| 102 |
+
default:
|
| 103 |
+
return { cartoon: { color: 'spectrum' } };
|
| 104 |
+
}
|
| 105 |
+
},
|
| 106 |
+
[]
|
| 107 |
+
);
|
| 108 |
+
|
| 109 |
+
const initViewer = useCallback(() => {
|
| 110 |
+
if (!containerRef.current || !$3Dmol || !pdbData) return;
|
| 111 |
+
|
| 112 |
+
setIsLoading(true);
|
| 113 |
+
setError(null);
|
| 114 |
+
|
| 115 |
+
try {
|
| 116 |
+
// Clean up existing viewer
|
| 117 |
+
if (viewerRef.current) {
|
| 118 |
+
viewerRef.current.removeAllModels();
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// Create viewer
|
| 122 |
+
const viewer = $3Dmol.createViewer(containerRef.current, {
|
| 123 |
+
backgroundColor: '#0a0a0a',
|
| 124 |
+
});
|
| 125 |
+
viewerRef.current = viewer;
|
| 126 |
+
|
| 127 |
+
// Add protein structure
|
| 128 |
+
viewer.addModel(pdbData, 'pdb');
|
| 129 |
+
|
| 130 |
+
if (representation === 'surface') {
|
| 131 |
+
viewer.setStyle({}, { cartoon: { color: 'spectrum' } }); // Base style
|
| 132 |
+
viewer.addSurface($3Dmol.SurfaceType.VDW, {
|
| 133 |
+
opacity: 0.85,
|
| 134 |
+
color: 'spectrum',
|
| 135 |
+
}, {}, {});
|
| 136 |
+
} else {
|
| 137 |
+
viewer.setStyle({}, getStyleForRepresentation(representation));
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
viewer.zoomTo();
|
| 141 |
+
viewer.render();
|
| 142 |
+
|
| 143 |
+
setIsLoading(false);
|
| 144 |
+
} catch (err) {
|
| 145 |
+
console.error('3D viewer error:', err);
|
| 146 |
+
setError(
|
| 147 |
+
err instanceof Error
|
| 148 |
+
? `Visualization error: ${err.message}`
|
| 149 |
+
: 'Failed to render 3D structure'
|
| 150 |
+
);
|
| 151 |
+
setIsLoading(false);
|
| 152 |
+
}
|
| 153 |
+
}, [$3Dmol, pdbData, representation, getStyleForRepresentation]);
|
| 154 |
+
|
| 155 |
+
useEffect(() => {
|
| 156 |
+
initViewer();
|
| 157 |
+
}, [$3Dmol, pdbData, initViewer]);
|
| 158 |
+
|
| 159 |
+
// Update representation
|
| 160 |
+
useEffect(() => {
|
| 161 |
+
if (!viewerRef.current || !pdbData || !$3Dmol) return;
|
| 162 |
+
try {
|
| 163 |
+
viewerRef.current.removeAllSurfaces();
|
| 164 |
+
|
| 165 |
+
if (representation === 'surface') {
|
| 166 |
+
viewerRef.current.setStyle({}, { cartoon: { color: 'spectrum', opacity: 0.5 } });
|
| 167 |
+
viewerRef.current.addSurface($3Dmol.SurfaceType.VDW, {
|
| 168 |
+
opacity: 0.85,
|
| 169 |
+
color: 'spectrum',
|
| 170 |
+
}, {}, {});
|
| 171 |
+
} else {
|
| 172 |
+
viewerRef.current.setStyle({}, getStyleForRepresentation(representation));
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
viewerRef.current.render();
|
| 176 |
+
} catch (err) {
|
| 177 |
+
console.error('Style update error:', err);
|
| 178 |
+
}
|
| 179 |
+
}, [representation, getStyleForRepresentation, pdbData, $3Dmol]);
|
| 180 |
+
|
| 181 |
+
const handleResetCamera = useCallback(() => {
|
| 182 |
+
if (!viewerRef.current) return;
|
| 183 |
+
viewerRef.current.zoomTo();
|
| 184 |
+
viewerRef.current.render();
|
| 185 |
+
}, []);
|
| 186 |
+
|
| 187 |
+
if (error) {
|
| 188 |
+
return (
|
| 189 |
+
<Card className={`border-destructive bg-destructive/10 ${className}`}>
|
| 190 |
+
<CardContent className="flex items-center gap-3 p-4">
|
| 191 |
+
<AlertCircle className="size-5 text-destructive" />
|
| 192 |
+
<div className="flex flex-col">
|
| 193 |
+
<span className="text-sm font-medium text-destructive">
|
| 194 |
+
Protein Visualization Error
|
| 195 |
+
</span>
|
| 196 |
+
<span className="text-xs text-muted-foreground">{error}</span>
|
| 197 |
+
</div>
|
| 198 |
+
</CardContent>
|
| 199 |
+
</Card>
|
| 200 |
+
);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
return (
|
| 204 |
+
<div className={`flex flex-col gap-3 ${className}`}>
|
| 205 |
+
<div className="flex items-center gap-2">
|
| 206 |
+
<Select
|
| 207 |
+
value={representation}
|
| 208 |
+
onValueChange={(value) =>
|
| 209 |
+
setRepresentation(value as ProteinRepresentation)
|
| 210 |
+
}
|
| 211 |
+
>
|
| 212 |
+
<SelectTrigger className="w-40">
|
| 213 |
+
<SelectValue placeholder="Representation" />
|
| 214 |
+
</SelectTrigger>
|
| 215 |
+
<SelectContent>
|
| 216 |
+
<SelectItem value="cartoon">Cartoon</SelectItem>
|
| 217 |
+
<SelectItem value="ball-and-stick">Ball & Stick</SelectItem>
|
| 218 |
+
<SelectItem value="surface">Surface</SelectItem>
|
| 219 |
+
<SelectItem value="ribbon">Ribbon</SelectItem>
|
| 220 |
+
</SelectContent>
|
| 221 |
+
</Select>
|
| 222 |
+
<Button variant="outline" size="sm" onClick={handleResetCamera}>
|
| 223 |
+
<RotateCcw className="mr-1 size-4" />
|
| 224 |
+
Reset
|
| 225 |
+
</Button>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
<div className="relative">
|
| 229 |
+
{isLoading && (
|
| 230 |
+
<Skeleton
|
| 231 |
+
className="absolute inset-0 z-10"
|
| 232 |
+
style={{ width, height }}
|
| 233 |
+
/>
|
| 234 |
+
)}
|
| 235 |
+
<div
|
| 236 |
+
ref={containerRef}
|
| 237 |
+
style={{ width, height }}
|
| 238 |
+
className={`rounded-lg border bg-background ${isLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-200`}
|
| 239 |
+
/>
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
);
|
| 243 |
+
}
|
ui/app/dashboard/proteins-3d/page.tsx
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { ExternalLink, Search } from 'lucide-react';
|
| 4 |
+
import dynamic from 'next/dynamic';
|
| 5 |
+
import { useEffect, useMemo, useState } from 'react';
|
| 6 |
+
|
| 7 |
+
import { Badge } from '@/components/ui/badge';
|
| 8 |
+
import { Button } from '@/components/ui/button';
|
| 9 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
| 10 |
+
import { Input } from '@/components/ui/input';
|
| 11 |
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
| 12 |
+
import { Separator } from '@/components/ui/separator';
|
| 13 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 14 |
+
import { getProteins, getProteinPdbUrl } from '@/lib/visualization-api';
|
| 15 |
+
import type { Protein } from '@/lib/visualization-types';
|
| 16 |
+
|
| 17 |
+
// Dynamic import of the protein viewer to prevent SSR issues
|
| 18 |
+
const ProteinViewer = dynamic(
|
| 19 |
+
() =>
|
| 20 |
+
import('./_components/ProteinViewer').then((mod) => mod.ProteinViewer),
|
| 21 |
+
{
|
| 22 |
+
ssr: false,
|
| 23 |
+
loading: () => <Skeleton className="size-[500px]" />,
|
| 24 |
+
}
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
export default function Proteins3DPage() {
|
| 28 |
+
const [proteins, setProteins] = useState<Protein[]>([]);
|
| 29 |
+
const [selectedProtein, setSelectedProtein] = useState<Protein | null>(null);
|
| 30 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 31 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 32 |
+
const [error, setError] = useState<string | null>(null);
|
| 33 |
+
|
| 34 |
+
// Load proteins list
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
const loadProteins = async () => {
|
| 37 |
+
try {
|
| 38 |
+
setIsLoading(true);
|
| 39 |
+
const data = await getProteins();
|
| 40 |
+
setProteins(data);
|
| 41 |
+
if (data.length > 0) {
|
| 42 |
+
setSelectedProtein(data[0]);
|
| 43 |
+
}
|
| 44 |
+
} catch (err) {
|
| 45 |
+
setError(
|
| 46 |
+
err instanceof Error ? err.message : 'Failed to load proteins'
|
| 47 |
+
);
|
| 48 |
+
} finally {
|
| 49 |
+
setIsLoading(false);
|
| 50 |
+
}
|
| 51 |
+
};
|
| 52 |
+
loadProteins();
|
| 53 |
+
}, []);
|
| 54 |
+
|
| 55 |
+
const filteredProteins = useMemo(() => {
|
| 56 |
+
if (!searchQuery.trim()) return proteins;
|
| 57 |
+
const query = searchQuery.toLowerCase();
|
| 58 |
+
return proteins.filter(
|
| 59 |
+
(p) =>
|
| 60 |
+
p.name.toLowerCase().includes(query) ||
|
| 61 |
+
p.pdbId.toLowerCase().includes(query) ||
|
| 62 |
+
p.description?.toLowerCase().includes(query)
|
| 63 |
+
);
|
| 64 |
+
}, [proteins, searchQuery]);
|
| 65 |
+
|
| 66 |
+
if (error) {
|
| 67 |
+
return (
|
| 68 |
+
<div className="flex h-full items-center justify-center">
|
| 69 |
+
<Card className="max-w-md">
|
| 70 |
+
<CardHeader>
|
| 71 |
+
<CardTitle className="text-destructive">Error</CardTitle>
|
| 72 |
+
<CardDescription>{error}</CardDescription>
|
| 73 |
+
</CardHeader>
|
| 74 |
+
</Card>
|
| 75 |
+
</div>
|
| 76 |
+
);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<div className="flex h-full gap-4 p-4">
|
| 81 |
+
{/* Left Panel - Protein List */}
|
| 82 |
+
<Card className="w-80 shrink-0">
|
| 83 |
+
<CardHeader className="pb-3">
|
| 84 |
+
<CardTitle className="text-lg">Proteins</CardTitle>
|
| 85 |
+
<CardDescription>Select a protein to visualize</CardDescription>
|
| 86 |
+
</CardHeader>
|
| 87 |
+
<CardContent className="pb-3">
|
| 88 |
+
<div className="relative">
|
| 89 |
+
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
| 90 |
+
<Input
|
| 91 |
+
placeholder="Search proteins..."
|
| 92 |
+
className="pl-8"
|
| 93 |
+
value={searchQuery}
|
| 94 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 95 |
+
/>
|
| 96 |
+
</div>
|
| 97 |
+
</CardContent>
|
| 98 |
+
<Separator />
|
| 99 |
+
<ScrollArea className="h-[calc(100vh-280px)]">
|
| 100 |
+
<div className="p-2">
|
| 101 |
+
{isLoading ? (
|
| 102 |
+
<div className="space-y-2">
|
| 103 |
+
{[1, 2, 3].map((i) => (
|
| 104 |
+
<Skeleton key={i} className="h-16 w-full" />
|
| 105 |
+
))}
|
| 106 |
+
</div>
|
| 107 |
+
) : filteredProteins.length === 0 ? (
|
| 108 |
+
<p className="p-4 text-center text-sm text-muted-foreground">
|
| 109 |
+
No proteins found
|
| 110 |
+
</p>
|
| 111 |
+
) : (
|
| 112 |
+
<div className="space-y-1">
|
| 113 |
+
{filteredProteins.map((protein) => (
|
| 114 |
+
<button
|
| 115 |
+
key={protein.id}
|
| 116 |
+
onClick={() => setSelectedProtein(protein)}
|
| 117 |
+
className={`w-full rounded-lg p-3 text-left transition-colors hover:bg-accent ${
|
| 118 |
+
selectedProtein?.id === protein.id
|
| 119 |
+
? 'bg-accent'
|
| 120 |
+
: 'bg-transparent'
|
| 121 |
+
}`}
|
| 122 |
+
>
|
| 123 |
+
<div className="flex items-center gap-2">
|
| 124 |
+
<span className="font-medium">{protein.name}</span>
|
| 125 |
+
<Badge variant="secondary" className="text-xs">
|
| 126 |
+
{protein.pdbId}
|
| 127 |
+
</Badge>
|
| 128 |
+
</div>
|
| 129 |
+
{protein.description && (
|
| 130 |
+
<div className="mt-1 truncate text-xs text-muted-foreground">
|
| 131 |
+
{protein.description}
|
| 132 |
+
</div>
|
| 133 |
+
)}
|
| 134 |
+
</button>
|
| 135 |
+
))}
|
| 136 |
+
</div>
|
| 137 |
+
)}
|
| 138 |
+
</div>
|
| 139 |
+
</ScrollArea>
|
| 140 |
+
</Card>
|
| 141 |
+
|
| 142 |
+
{/* Right Panel - 3D Visualization */}
|
| 143 |
+
<div className="flex flex-1 flex-col gap-6">
|
| 144 |
+
{selectedProtein ? (
|
| 145 |
+
<>
|
| 146 |
+
{/* Protein Info Card */}
|
| 147 |
+
<Card>
|
| 148 |
+
<CardHeader className="pb-3">
|
| 149 |
+
<div className="flex items-start justify-between">
|
| 150 |
+
<div>
|
| 151 |
+
<CardTitle>{selectedProtein.name}</CardTitle>
|
| 152 |
+
<CardDescription>
|
| 153 |
+
{selectedProtein.description}
|
| 154 |
+
</CardDescription>
|
| 155 |
+
</div>
|
| 156 |
+
<div className="flex items-center gap-2">
|
| 157 |
+
<Badge variant="outline">PDB: {selectedProtein.pdbId}</Badge>
|
| 158 |
+
<Button
|
| 159 |
+
variant="outline"
|
| 160 |
+
size="sm"
|
| 161 |
+
asChild
|
| 162 |
+
>
|
| 163 |
+
<a
|
| 164 |
+
href={getProteinPdbUrl(selectedProtein.id)}
|
| 165 |
+
target="_blank"
|
| 166 |
+
rel="noopener noreferrer"
|
| 167 |
+
>
|
| 168 |
+
<ExternalLink className="mr-1 size-4" />
|
| 169 |
+
Open PDB
|
| 170 |
+
</a>
|
| 171 |
+
</Button>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</CardHeader>
|
| 175 |
+
<CardContent>
|
| 176 |
+
<div className="flex gap-4 text-sm text-muted-foreground">
|
| 177 |
+
<a
|
| 178 |
+
href={`https://www.rcsb.org/structure/${selectedProtein.pdbId}`}
|
| 179 |
+
target="_blank"
|
| 180 |
+
rel="noopener noreferrer"
|
| 181 |
+
className="flex items-center gap-1 hover:text-foreground"
|
| 182 |
+
>
|
| 183 |
+
<ExternalLink className="size-3" />
|
| 184 |
+
View on RCSB PDB
|
| 185 |
+
</a>
|
| 186 |
+
<a
|
| 187 |
+
href={`https://www.uniprot.org/uniprotkb?query=${selectedProtein.name}`}
|
| 188 |
+
target="_blank"
|
| 189 |
+
rel="noopener noreferrer"
|
| 190 |
+
className="flex items-center gap-1 hover:text-foreground"
|
| 191 |
+
>
|
| 192 |
+
<ExternalLink className="size-3" />
|
| 193 |
+
Search UniProt
|
| 194 |
+
</a>
|
| 195 |
+
</div>
|
| 196 |
+
</CardContent>
|
| 197 |
+
</Card>
|
| 198 |
+
|
| 199 |
+
{/* 3D Structure Card */}
|
| 200 |
+
<Card className="flex-1">
|
| 201 |
+
<CardHeader>
|
| 202 |
+
<CardTitle className="text-lg">3D Structure</CardTitle>
|
| 203 |
+
<CardDescription>
|
| 204 |
+
Rotate: click + drag • Zoom: scroll • Pan: right-click + drag
|
| 205 |
+
</CardDescription>
|
| 206 |
+
</CardHeader>
|
| 207 |
+
<CardContent className="flex items-center justify-center">
|
| 208 |
+
<ProteinViewer
|
| 209 |
+
key={selectedProtein.pdbId}
|
| 210 |
+
pdbId={selectedProtein.pdbId}
|
| 211 |
+
width={600}
|
| 212 |
+
height={500}
|
| 213 |
+
/>
|
| 214 |
+
</CardContent>
|
| 215 |
+
</Card>
|
| 216 |
+
</>
|
| 217 |
+
) : (
|
| 218 |
+
<div className="flex flex-1 items-center justify-center">
|
| 219 |
+
<p className="text-muted-foreground">
|
| 220 |
+
Select a protein to view its 3D structure
|
| 221 |
+
</p>
|
| 222 |
+
</div>
|
| 223 |
+
)}
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
);
|
| 227 |
+
}
|
ui/app/dashboard/settings/page.tsx
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { Brain, Save,Settings } from "lucide-react"
|
| 4 |
+
|
| 5 |
+
import { PageHeader, SectionHeader } from "@/components/page-header"
|
| 6 |
+
import { Button } from "@/components/ui/button"
|
| 7 |
+
import { Card, CardContent, CardDescription,CardHeader, CardTitle } from "@/components/ui/card"
|
| 8 |
+
import { Label } from "@/components/ui/label"
|
| 9 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
| 10 |
+
import { Slider } from "@/components/ui/slider"
|
| 11 |
+
import { Switch } from "@/components/ui/switch"
|
| 12 |
+
import { Tabs, TabsContent,TabsList, TabsTrigger } from "@/components/ui/tabs"
|
| 13 |
+
|
| 14 |
+
export default function SettingsPage() {
|
| 15 |
+
return (
|
| 16 |
+
<div className="space-y-8 animate-in fade-in duration-500">
|
| 17 |
+
<PageHeader
|
| 18 |
+
title="Settings"
|
| 19 |
+
subtitle="Configure models, databases, and preferences"
|
| 20 |
+
icon={<Settings className="h-8 w-8 text-primary" />}
|
| 21 |
+
/>
|
| 22 |
+
|
| 23 |
+
<Tabs defaultValue="models" className="w-full">
|
| 24 |
+
<TabsList className="w-full justify-start overflow-x-auto">
|
| 25 |
+
<TabsTrigger value="models">Models</TabsTrigger>
|
| 26 |
+
<TabsTrigger value="database">Database</TabsTrigger>
|
| 27 |
+
<TabsTrigger value="api">API Keys</TabsTrigger>
|
| 28 |
+
<TabsTrigger value="appearance">Appearance</TabsTrigger>
|
| 29 |
+
<TabsTrigger value="system">System</TabsTrigger>
|
| 30 |
+
</TabsList>
|
| 31 |
+
<TabsContent value="models" className="space-y-6 mt-6">
|
| 32 |
+
<SectionHeader title="Model Configuration" icon={<Brain className="h-5 w-5 text-primary" />} />
|
| 33 |
+
|
| 34 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 35 |
+
<Card>
|
| 36 |
+
<CardHeader>
|
| 37 |
+
<CardTitle>Embedding Models</CardTitle>
|
| 38 |
+
<CardDescription>Configure models used for molecular and protein embeddings</CardDescription>
|
| 39 |
+
</CardHeader>
|
| 40 |
+
<CardContent className="space-y-4">
|
| 41 |
+
<div className="space-y-2">
|
| 42 |
+
<Label htmlFor="mol-encoder">Molecule Encoder</Label>
|
| 43 |
+
<Select defaultValue="MolCLR">
|
| 44 |
+
<SelectTrigger id="mol-encoder">
|
| 45 |
+
<SelectValue placeholder="Select molecule encoder" />
|
| 46 |
+
</SelectTrigger>
|
| 47 |
+
<SelectContent>
|
| 48 |
+
<SelectItem value="MolCLR">MolCLR (Recommended)</SelectItem>
|
| 49 |
+
<SelectItem value="ChemBERTa">ChemBERTa</SelectItem>
|
| 50 |
+
<SelectItem value="GraphMVP">GraphMVP</SelectItem>
|
| 51 |
+
<SelectItem value="MolBERT">MolBERT</SelectItem>
|
| 52 |
+
</SelectContent>
|
| 53 |
+
</Select>
|
| 54 |
+
</div>
|
| 55 |
+
<div className="space-y-2">
|
| 56 |
+
<Label htmlFor="prot-encoder">Protein Encoder</Label>
|
| 57 |
+
<Select defaultValue="ESM-2">
|
| 58 |
+
<SelectTrigger id="prot-encoder">
|
| 59 |
+
<SelectValue placeholder="Select protein encoder" />
|
| 60 |
+
</SelectTrigger>
|
| 61 |
+
<SelectContent>
|
| 62 |
+
<SelectItem value="ESM-2">ESM-2 (Recommended)</SelectItem>
|
| 63 |
+
<SelectItem value="ProtTrans">ProtTrans</SelectItem>
|
| 64 |
+
<SelectItem value="UniRep">UniRep</SelectItem>
|
| 65 |
+
<SelectItem value="SeqVec">SeqVec</SelectItem>
|
| 66 |
+
</SelectContent>
|
| 67 |
+
</Select>
|
| 68 |
+
</div>
|
| 69 |
+
</CardContent>
|
| 70 |
+
</Card>
|
| 71 |
+
|
| 72 |
+
<Card>
|
| 73 |
+
<CardHeader>
|
| 74 |
+
<CardTitle>Prediction Heads</CardTitle>
|
| 75 |
+
<CardDescription>Configure downstream task predictors</CardDescription>
|
| 76 |
+
</CardHeader>
|
| 77 |
+
<CardContent className="space-y-4">
|
| 78 |
+
<div className="space-y-2">
|
| 79 |
+
<Label htmlFor="binding">Binding Predictor</Label>
|
| 80 |
+
<Select defaultValue="DrugBAN">
|
| 81 |
+
<SelectTrigger id="binding">
|
| 82 |
+
<SelectValue placeholder="Select predictor" />
|
| 83 |
+
</SelectTrigger>
|
| 84 |
+
<SelectContent>
|
| 85 |
+
<SelectItem value="DrugBAN">DrugBAN (Recommended)</SelectItem>
|
| 86 |
+
<SelectItem value="DeepDTA">DeepDTA</SelectItem>
|
| 87 |
+
<SelectItem value="GraphDTA">GraphDTA</SelectItem>
|
| 88 |
+
<SelectItem value="Custom">Custom</SelectItem>
|
| 89 |
+
</SelectContent>
|
| 90 |
+
</Select>
|
| 91 |
+
</div>
|
| 92 |
+
<div className="space-y-2">
|
| 93 |
+
<Label htmlFor="property">Property Predictor</Label>
|
| 94 |
+
<Select defaultValue="ADMET-AI">
|
| 95 |
+
<SelectTrigger id="property">
|
| 96 |
+
<SelectValue placeholder="Select predictor" />
|
| 97 |
+
</SelectTrigger>
|
| 98 |
+
<SelectContent>
|
| 99 |
+
<SelectItem value="ADMET-AI">ADMET-AI (Recommended)</SelectItem>
|
| 100 |
+
<SelectItem value="ChemProp">ChemProp</SelectItem>
|
| 101 |
+
<SelectItem value="Custom">Custom</SelectItem>
|
| 102 |
+
</SelectContent>
|
| 103 |
+
</Select>
|
| 104 |
+
</div>
|
| 105 |
+
</CardContent>
|
| 106 |
+
</Card>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<Card>
|
| 110 |
+
<CardHeader>
|
| 111 |
+
<CardTitle>LLM Settings</CardTitle>
|
| 112 |
+
<CardDescription>Configure language models for evidence retrieval and reasoning</CardDescription>
|
| 113 |
+
</CardHeader>
|
| 114 |
+
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 115 |
+
<div className="space-y-2">
|
| 116 |
+
<Label htmlFor="llm-provider">LLM Provider</Label>
|
| 117 |
+
<Select defaultValue="OpenAI">
|
| 118 |
+
<SelectTrigger id="llm-provider">
|
| 119 |
+
<SelectValue placeholder="Select provider" />
|
| 120 |
+
</SelectTrigger>
|
| 121 |
+
<SelectContent>
|
| 122 |
+
<SelectItem value="OpenAI">OpenAI</SelectItem>
|
| 123 |
+
<SelectItem value="Anthropic">Anthropic</SelectItem>
|
| 124 |
+
<SelectItem value="Local">Local (Ollama)</SelectItem>
|
| 125 |
+
<SelectItem value="Azure">Azure OpenAI</SelectItem>
|
| 126 |
+
</SelectContent>
|
| 127 |
+
</Select>
|
| 128 |
+
</div>
|
| 129 |
+
<div className="space-y-2">
|
| 130 |
+
<Label htmlFor="llm-model">Model</Label>
|
| 131 |
+
<Select defaultValue="GPT-4o">
|
| 132 |
+
<SelectTrigger id="llm-model">
|
| 133 |
+
<SelectValue placeholder="Select model" />
|
| 134 |
+
</SelectTrigger>
|
| 135 |
+
<SelectContent>
|
| 136 |
+
<SelectItem value="GPT-4o">GPT-4o</SelectItem>
|
| 137 |
+
<SelectItem value="GPT-4-turbo">GPT-4-turbo</SelectItem>
|
| 138 |
+
<SelectItem value="Claude 3.5">Claude 3.5 Sonnet</SelectItem>
|
| 139 |
+
<SelectItem value="Llama 3">Llama 3.1 70B</SelectItem>
|
| 140 |
+
</SelectContent>
|
| 141 |
+
</Select>
|
| 142 |
+
</div>
|
| 143 |
+
<div className="col-span-1 md:col-span-2 space-y-4">
|
| 144 |
+
<div className="space-y-2">
|
| 145 |
+
<div className="flex items-center justify-between">
|
| 146 |
+
<Label>Temperature: 0.7</Label>
|
| 147 |
+
<span className="text-xs text-muted-foreground">Creativity vs Precision</span>
|
| 148 |
+
</div>
|
| 149 |
+
<Slider defaultValue={[0.7]} max={1} step={0.1} />
|
| 150 |
+
</div>
|
| 151 |
+
<div className="flex items-center space-x-2">
|
| 152 |
+
<Switch id="stream" defaultChecked />
|
| 153 |
+
<Label htmlFor="stream">Stream Responses</Label>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
</CardContent>
|
| 157 |
+
</Card>
|
| 158 |
+
</TabsContent>
|
| 159 |
+
|
| 160 |
+
<TabsContent value="appearance">
|
| 161 |
+
<Card>
|
| 162 |
+
<CardContent className="p-12 text-center text-muted-foreground">
|
| 163 |
+
Theme settings coming soon.
|
| 164 |
+
</CardContent>
|
| 165 |
+
</Card>
|
| 166 |
+
</TabsContent>
|
| 167 |
+
|
| 168 |
+
<TabsContent value="database">
|
| 169 |
+
<Card>
|
| 170 |
+
<CardContent className="p-12 text-center text-muted-foreground">
|
| 171 |
+
Database connection settings.
|
| 172 |
+
</CardContent>
|
| 173 |
+
</Card>
|
| 174 |
+
</TabsContent>
|
| 175 |
+
|
| 176 |
+
<TabsContent value="api">
|
| 177 |
+
<Card>
|
| 178 |
+
<CardContent className="p-12 text-center text-muted-foreground">
|
| 179 |
+
API Key configuration.
|
| 180 |
+
</CardContent>
|
| 181 |
+
</Card>
|
| 182 |
+
</TabsContent>
|
| 183 |
+
</Tabs>
|
| 184 |
+
|
| 185 |
+
<div className="fixed bottom-6 right-6">
|
| 186 |
+
<Button size="lg" className="shadow-2xl">
|
| 187 |
+
<Save className="mr-2 h-4 w-4" />
|
| 188 |
+
Save Changes
|
| 189 |
+
</Button>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
)
|
| 193 |
+
}
|
ui/app/dashboard/visualization/page.tsx
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Download, ExternalLink, Filter, Loader2, RotateCcw, Search, ZoomIn, ZoomOut } from "lucide-react"
|
| 5 |
+
|
| 6 |
+
import { Button } from "@/components/ui/button"
|
| 7 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
| 8 |
+
import { Input } from "@/components/ui/input"
|
| 9 |
+
import { Label } from "@/components/ui/label"
|
| 10 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
| 11 |
+
import { Slider } from "@/components/ui/slider"
|
| 12 |
+
import { Badge } from "@/components/ui/badge"
|
| 13 |
+
import { ScrollArea } from "@/components/ui/scroll-area"
|
| 14 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
| 15 |
+
|
| 16 |
+
// Types
|
| 17 |
+
interface EmbeddingPoint {
|
| 18 |
+
id: string
|
| 19 |
+
x: number
|
| 20 |
+
y: number
|
| 21 |
+
z: number
|
| 22 |
+
label: string
|
| 23 |
+
content: string
|
| 24 |
+
modality: string
|
| 25 |
+
source: string
|
| 26 |
+
score?: number
|
| 27 |
+
metadata?: Record<string, unknown>
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
interface SearchResult {
|
| 31 |
+
id: string
|
| 32 |
+
content: string
|
| 33 |
+
score: number
|
| 34 |
+
modality: string
|
| 35 |
+
source: string
|
| 36 |
+
evidence_links?: Array<{
|
| 37 |
+
source: string
|
| 38 |
+
identifier: string
|
| 39 |
+
url: string
|
| 40 |
+
label: string
|
| 41 |
+
}>
|
| 42 |
+
citation?: string
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// 3D Canvas Component using CSS transforms (no Three.js dependency)
|
| 46 |
+
function Scatter3DCanvas({
|
| 47 |
+
points,
|
| 48 |
+
selectedPoint,
|
| 49 |
+
onSelectPoint,
|
| 50 |
+
rotation,
|
| 51 |
+
zoom,
|
| 52 |
+
}: {
|
| 53 |
+
points: EmbeddingPoint[]
|
| 54 |
+
selectedPoint: EmbeddingPoint | null
|
| 55 |
+
onSelectPoint: (point: EmbeddingPoint | null) => void
|
| 56 |
+
rotation: { x: number; y: number }
|
| 57 |
+
zoom: number
|
| 58 |
+
}) {
|
| 59 |
+
const containerRef = React.useRef<HTMLDivElement>(null)
|
| 60 |
+
|
| 61 |
+
// Color by modality
|
| 62 |
+
const getColor = (modality: string) => {
|
| 63 |
+
switch (modality) {
|
| 64 |
+
case "text": return "#3b82f6" // blue
|
| 65 |
+
case "molecule": return "#22c55e" // green
|
| 66 |
+
case "protein": return "#f59e0b" // amber
|
| 67 |
+
default: return "#8b5cf6" // purple
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// Project 3D to 2D with rotation
|
| 72 |
+
const project = (point: EmbeddingPoint) => {
|
| 73 |
+
const rad = Math.PI / 180
|
| 74 |
+
const cosX = Math.cos(rotation.x * rad)
|
| 75 |
+
const sinX = Math.sin(rotation.x * rad)
|
| 76 |
+
const cosY = Math.cos(rotation.y * rad)
|
| 77 |
+
const sinY = Math.sin(rotation.y * rad)
|
| 78 |
+
|
| 79 |
+
// Rotate around Y axis
|
| 80 |
+
let x = point.x * cosY - point.z * sinY
|
| 81 |
+
let z = point.x * sinY + point.z * cosY
|
| 82 |
+
|
| 83 |
+
// Rotate around X axis
|
| 84 |
+
const y = point.y * cosX - z * sinX
|
| 85 |
+
z = point.y * sinX + z * cosX
|
| 86 |
+
|
| 87 |
+
// Simple perspective projection
|
| 88 |
+
const perspective = 500
|
| 89 |
+
const scale = perspective / (perspective + z * 50)
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
x: 250 + x * 100 * zoom * scale,
|
| 93 |
+
y: 250 - y * 100 * zoom * scale,
|
| 94 |
+
scale,
|
| 95 |
+
z,
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Sort points by z for proper rendering order
|
| 100 |
+
const sortedPoints = [...points]
|
| 101 |
+
.map(p => ({ ...p, projected: project(p) }))
|
| 102 |
+
.sort((a, b) => a.projected.z - b.projected.z)
|
| 103 |
+
|
| 104 |
+
return (
|
| 105 |
+
<div
|
| 106 |
+
ref={containerRef}
|
| 107 |
+
className="relative w-full h-[500px] bg-gradient-to-br from-slate-900 to-slate-800 rounded-lg overflow-hidden"
|
| 108 |
+
style={{ perspective: "500px" }}
|
| 109 |
+
>
|
| 110 |
+
{/* Axis lines */}
|
| 111 |
+
<svg className="absolute inset-0 w-full h-full pointer-events-none opacity-20">
|
| 112 |
+
<line x1="50" y1="250" x2="450" y2="250" stroke="#fff" strokeWidth="1" />
|
| 113 |
+
<line x1="250" y1="50" x2="250" y2="450" stroke="#fff" strokeWidth="1" />
|
| 114 |
+
<text x="460" y="255" fill="#fff" fontSize="12">X</text>
|
| 115 |
+
<text x="255" y="40" fill="#fff" fontSize="12">Y</text>
|
| 116 |
+
</svg>
|
| 117 |
+
|
| 118 |
+
{/* Points */}
|
| 119 |
+
{sortedPoints.map((point) => {
|
| 120 |
+
const { x, y, scale } = point.projected
|
| 121 |
+
const size = Math.max(6, 12 * scale)
|
| 122 |
+
const isSelected = selectedPoint?.id === point.id
|
| 123 |
+
|
| 124 |
+
return (
|
| 125 |
+
<button
|
| 126 |
+
key={point.id}
|
| 127 |
+
className="absolute rounded-full transition-all duration-150 cursor-pointer hover:ring-2 hover:ring-white/50"
|
| 128 |
+
style={{
|
| 129 |
+
left: x - size / 2,
|
| 130 |
+
top: y - size / 2,
|
| 131 |
+
width: size,
|
| 132 |
+
height: size,
|
| 133 |
+
backgroundColor: getColor(point.modality),
|
| 134 |
+
opacity: 0.5 + scale * 0.5,
|
| 135 |
+
transform: isSelected ? "scale(1.5)" : "scale(1)",
|
| 136 |
+
boxShadow: isSelected ? `0 0 20px ${getColor(point.modality)}` : "none",
|
| 137 |
+
zIndex: Math.floor(scale * 100),
|
| 138 |
+
}}
|
| 139 |
+
onClick={() => onSelectPoint(isSelected ? null : point)}
|
| 140 |
+
title={point.label}
|
| 141 |
+
/>
|
| 142 |
+
)
|
| 143 |
+
})}
|
| 144 |
+
|
| 145 |
+
{/* Legend */}
|
| 146 |
+
<div className="absolute bottom-4 left-4 flex gap-4 text-xs text-white/70">
|
| 147 |
+
<div className="flex items-center gap-1">
|
| 148 |
+
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
| 149 |
+
<span>Text</span>
|
| 150 |
+
</div>
|
| 151 |
+
<div className="flex items-center gap-1">
|
| 152 |
+
<div className="w-3 h-3 rounded-full bg-green-500" />
|
| 153 |
+
<span>Molecule</span>
|
| 154 |
+
</div>
|
| 155 |
+
<div className="flex items-center gap-1">
|
| 156 |
+
<div className="w-3 h-3 rounded-full bg-amber-500" />
|
| 157 |
+
<span>Protein</span>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
)
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// Evidence Panel Component
|
| 165 |
+
function EvidencePanel({ result }: { result: SearchResult | null }) {
|
| 166 |
+
if (!result) {
|
| 167 |
+
return (
|
| 168 |
+
<Card className="h-full">
|
| 169 |
+
<CardHeader>
|
| 170 |
+
<CardTitle className="text-lg">Evidence Panel</CardTitle>
|
| 171 |
+
<CardDescription>Select a point to view details</CardDescription>
|
| 172 |
+
</CardHeader>
|
| 173 |
+
<CardContent>
|
| 174 |
+
<div className="text-center text-muted-foreground py-8">
|
| 175 |
+
Click on a point in the 3D view to see its evidence trail
|
| 176 |
+
</div>
|
| 177 |
+
</CardContent>
|
| 178 |
+
</Card>
|
| 179 |
+
)
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
return (
|
| 183 |
+
<Card className="h-full">
|
| 184 |
+
<CardHeader className="pb-3">
|
| 185 |
+
<div className="flex items-center justify-between">
|
| 186 |
+
<CardTitle className="text-lg">Evidence</CardTitle>
|
| 187 |
+
<Badge variant="outline">{result.modality}</Badge>
|
| 188 |
+
</div>
|
| 189 |
+
<CardDescription>Score: {result.score.toFixed(3)}</CardDescription>
|
| 190 |
+
</CardHeader>
|
| 191 |
+
<CardContent>
|
| 192 |
+
<ScrollArea className="h-[400px] pr-4">
|
| 193 |
+
{/* Content */}
|
| 194 |
+
<div className="space-y-4">
|
| 195 |
+
<div>
|
| 196 |
+
<Label className="text-xs text-muted-foreground">Content</Label>
|
| 197 |
+
<p className="text-sm mt-1 bg-muted/50 p-3 rounded-lg">
|
| 198 |
+
{result.content.slice(0, 300)}
|
| 199 |
+
{result.content.length > 300 && "..."}
|
| 200 |
+
</p>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
{/* Source */}
|
| 204 |
+
<div>
|
| 205 |
+
<Label className="text-xs text-muted-foreground">Source</Label>
|
| 206 |
+
<Badge className="mt-1">{result.source}</Badge>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
{/* Citation */}
|
| 210 |
+
{result.citation && (
|
| 211 |
+
<div>
|
| 212 |
+
<Label className="text-xs text-muted-foreground">Citation</Label>
|
| 213 |
+
<p className="text-sm mt-1 italic">{result.citation}</p>
|
| 214 |
+
</div>
|
| 215 |
+
)}
|
| 216 |
+
|
| 217 |
+
{/* Evidence Links */}
|
| 218 |
+
{result.evidence_links && result.evidence_links.length > 0 && (
|
| 219 |
+
<div>
|
| 220 |
+
<Label className="text-xs text-muted-foreground">External Links</Label>
|
| 221 |
+
<div className="mt-2 space-y-2">
|
| 222 |
+
{result.evidence_links.map((link, idx) => (
|
| 223 |
+
<a
|
| 224 |
+
key={idx}
|
| 225 |
+
href={link.url}
|
| 226 |
+
target="_blank"
|
| 227 |
+
rel="noopener noreferrer"
|
| 228 |
+
className="flex items-center gap-2 text-sm text-primary hover:underline"
|
| 229 |
+
>
|
| 230 |
+
<ExternalLink className="h-3 w-3" />
|
| 231 |
+
{link.label}
|
| 232 |
+
</a>
|
| 233 |
+
))}
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
)}
|
| 237 |
+
</div>
|
| 238 |
+
</ScrollArea>
|
| 239 |
+
</CardContent>
|
| 240 |
+
</Card>
|
| 241 |
+
)
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
// Export functions
|
| 245 |
+
function exportToCSV(results: SearchResult[]) {
|
| 246 |
+
const headers = ["id", "content", "score", "modality", "source", "citation"]
|
| 247 |
+
const rows = results.map(r => [
|
| 248 |
+
r.id,
|
| 249 |
+
`"${r.content.replace(/"/g, '""')}"`,
|
| 250 |
+
r.score,
|
| 251 |
+
r.modality,
|
| 252 |
+
r.source,
|
| 253 |
+
r.citation || "",
|
| 254 |
+
])
|
| 255 |
+
|
| 256 |
+
const csv = [headers.join(","), ...rows.map(r => r.join(","))].join("\n")
|
| 257 |
+
const blob = new Blob([csv], { type: "text/csv" })
|
| 258 |
+
const url = URL.createObjectURL(blob)
|
| 259 |
+
const a = document.createElement("a")
|
| 260 |
+
a.href = url
|
| 261 |
+
a.download = `bioflow_results_${Date.now()}.csv`
|
| 262 |
+
a.click()
|
| 263 |
+
URL.revokeObjectURL(url)
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
function exportToJSON(results: SearchResult[]) {
|
| 267 |
+
const json = JSON.stringify(results, null, 2)
|
| 268 |
+
const blob = new Blob([json], { type: "application/json" })
|
| 269 |
+
const url = URL.createObjectURL(blob)
|
| 270 |
+
const a = document.createElement("a")
|
| 271 |
+
a.href = url
|
| 272 |
+
a.download = `bioflow_results_${Date.now()}.json`
|
| 273 |
+
a.click()
|
| 274 |
+
URL.revokeObjectURL(url)
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function exportToFASTA(results: SearchResult[]) {
|
| 278 |
+
const fasta = results
|
| 279 |
+
.filter(r => r.modality === "protein")
|
| 280 |
+
.map(r => `>${r.id}\n${r.content}`)
|
| 281 |
+
.join("\n\n")
|
| 282 |
+
|
| 283 |
+
if (!fasta) {
|
| 284 |
+
alert("No protein sequences to export")
|
| 285 |
+
return
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
const blob = new Blob([fasta], { type: "text/plain" })
|
| 289 |
+
const url = URL.createObjectURL(blob)
|
| 290 |
+
const a = document.createElement("a")
|
| 291 |
+
a.href = url
|
| 292 |
+
a.download = `bioflow_proteins_${Date.now()}.fasta`
|
| 293 |
+
a.click()
|
| 294 |
+
URL.revokeObjectURL(url)
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
// Main Visualization Page
|
| 298 |
+
export default function VisualizationPage() {
|
| 299 |
+
const [isLoading, setIsLoading] = React.useState(false)
|
| 300 |
+
const [query, setQuery] = React.useState("")
|
| 301 |
+
const [points, setPoints] = React.useState<EmbeddingPoint[]>([])
|
| 302 |
+
const [results, setResults] = React.useState<SearchResult[]>([])
|
| 303 |
+
const [selectedPoint, setSelectedPoint] = React.useState<EmbeddingPoint | null>(null)
|
| 304 |
+
const [selectedResult, setSelectedResult] = React.useState<SearchResult | null>(null)
|
| 305 |
+
const [rotation, setRotation] = React.useState({ x: 15, y: 30 })
|
| 306 |
+
const [zoom, setZoom] = React.useState(1)
|
| 307 |
+
const [modalityFilter, setModalityFilter] = React.useState("all")
|
| 308 |
+
const [isDragging, setIsDragging] = React.useState(false)
|
| 309 |
+
const [lastMousePos, setLastMousePos] = React.useState({ x: 0, y: 0 })
|
| 310 |
+
|
| 311 |
+
// Handle mouse drag for rotation
|
| 312 |
+
const handleMouseDown = (e: React.MouseEvent) => {
|
| 313 |
+
setIsDragging(true)
|
| 314 |
+
setLastMousePos({ x: e.clientX, y: e.clientY })
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
const handleMouseMove = (e: React.MouseEvent) => {
|
| 318 |
+
if (!isDragging) return
|
| 319 |
+
const dx = e.clientX - lastMousePos.x
|
| 320 |
+
const dy = e.clientY - lastMousePos.y
|
| 321 |
+
setRotation(prev => ({
|
| 322 |
+
x: prev.x + dy * 0.5,
|
| 323 |
+
y: prev.y + dx * 0.5,
|
| 324 |
+
}))
|
| 325 |
+
setLastMousePos({ x: e.clientX, y: e.clientY })
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
const handleMouseUp = () => {
|
| 329 |
+
setIsDragging(false)
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
// Search and load embeddings
|
| 333 |
+
const handleSearch = async () => {
|
| 334 |
+
if (!query.trim()) return
|
| 335 |
+
|
| 336 |
+
setIsLoading(true)
|
| 337 |
+
try {
|
| 338 |
+
const response = await fetch("/api/search", {
|
| 339 |
+
method: "POST",
|
| 340 |
+
headers: { "Content-Type": "application/json" },
|
| 341 |
+
body: JSON.stringify({
|
| 342 |
+
query,
|
| 343 |
+
top_k: 50,
|
| 344 |
+
use_mmr: true,
|
| 345 |
+
}),
|
| 346 |
+
})
|
| 347 |
+
|
| 348 |
+
const data = await response.json()
|
| 349 |
+
|
| 350 |
+
if (data.results) {
|
| 351 |
+
// Convert to points with random 3D positions (in real use, these would come from UMAP/t-SNE)
|
| 352 |
+
const newPoints: EmbeddingPoint[] = data.results.map((r: SearchResult, idx: number) => {
|
| 353 |
+
// Create pseudo-3D positions based on score and index
|
| 354 |
+
const angle = (idx / data.results.length) * Math.PI * 2
|
| 355 |
+
const radius = 1 - r.score
|
| 356 |
+
return {
|
| 357 |
+
id: r.id || `point-${idx}`,
|
| 358 |
+
x: Math.cos(angle) * radius + (Math.random() - 0.5) * 0.3,
|
| 359 |
+
y: r.score * 2 - 1 + (Math.random() - 0.5) * 0.2,
|
| 360 |
+
z: Math.sin(angle) * radius + (Math.random() - 0.5) * 0.3,
|
| 361 |
+
label: r.content.slice(0, 50) + "...",
|
| 362 |
+
content: r.content,
|
| 363 |
+
modality: r.modality,
|
| 364 |
+
source: r.source,
|
| 365 |
+
score: r.score,
|
| 366 |
+
}
|
| 367 |
+
})
|
| 368 |
+
|
| 369 |
+
setPoints(newPoints)
|
| 370 |
+
setResults(data.results)
|
| 371 |
+
}
|
| 372 |
+
} catch (err) {
|
| 373 |
+
console.error("Search failed:", err)
|
| 374 |
+
} finally {
|
| 375 |
+
setIsLoading(false)
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
// Filter points by modality
|
| 380 |
+
const filteredPoints = modalityFilter === "all"
|
| 381 |
+
? points
|
| 382 |
+
: points.filter(p => p.modality === modalityFilter)
|
| 383 |
+
|
| 384 |
+
// Handle point selection
|
| 385 |
+
const handleSelectPoint = (point: EmbeddingPoint | null) => {
|
| 386 |
+
setSelectedPoint(point)
|
| 387 |
+
if (point) {
|
| 388 |
+
const result = results.find(r => r.id === point.id || r.content === point.content)
|
| 389 |
+
setSelectedResult(result || null)
|
| 390 |
+
} else {
|
| 391 |
+
setSelectedResult(null)
|
| 392 |
+
}
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
return (
|
| 396 |
+
<div className="container mx-auto p-6 space-y-6">
|
| 397 |
+
{/* Header */}
|
| 398 |
+
<div className="flex flex-col space-y-2">
|
| 399 |
+
<h1 className="text-3xl font-bold tracking-tight">3D Embedding Explorer</h1>
|
| 400 |
+
<p className="text-muted-foreground">
|
| 401 |
+
Visualize and explore multimodal embeddings in 3D space. Search, filter, and examine evidence trails.
|
| 402 |
+
</p>
|
| 403 |
+
</div>
|
| 404 |
+
|
| 405 |
+
{/* Search Bar */}
|
| 406 |
+
<Card>
|
| 407 |
+
<CardContent className="pt-6">
|
| 408 |
+
<div className="flex gap-4">
|
| 409 |
+
<div className="flex-1">
|
| 410 |
+
<Input
|
| 411 |
+
placeholder="Search for molecules, proteins, or literature..."
|
| 412 |
+
value={query}
|
| 413 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 414 |
+
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
| 415 |
+
/>
|
| 416 |
+
</div>
|
| 417 |
+
<Button onClick={handleSearch} disabled={isLoading}>
|
| 418 |
+
{isLoading ? (
|
| 419 |
+
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
| 420 |
+
) : (
|
| 421 |
+
<Search className="h-4 w-4 mr-2" />
|
| 422 |
+
)}
|
| 423 |
+
Search
|
| 424 |
+
</Button>
|
| 425 |
+
</div>
|
| 426 |
+
</CardContent>
|
| 427 |
+
</Card>
|
| 428 |
+
|
| 429 |
+
{/* Main Content */}
|
| 430 |
+
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
| 431 |
+
{/* Controls */}
|
| 432 |
+
<Card className="lg:col-span-1">
|
| 433 |
+
<CardHeader>
|
| 434 |
+
<CardTitle className="text-lg">Controls</CardTitle>
|
| 435 |
+
</CardHeader>
|
| 436 |
+
<CardContent className="space-y-6">
|
| 437 |
+
{/* Modality Filter */}
|
| 438 |
+
<div className="space-y-2">
|
| 439 |
+
<Label>Filter by Modality</Label>
|
| 440 |
+
<Select value={modalityFilter} onValueChange={setModalityFilter}>
|
| 441 |
+
<SelectTrigger>
|
| 442 |
+
<SelectValue />
|
| 443 |
+
</SelectTrigger>
|
| 444 |
+
<SelectContent>
|
| 445 |
+
<SelectItem value="all">All</SelectItem>
|
| 446 |
+
<SelectItem value="text">Text</SelectItem>
|
| 447 |
+
<SelectItem value="molecule">Molecule</SelectItem>
|
| 448 |
+
<SelectItem value="protein">Protein</SelectItem>
|
| 449 |
+
</SelectContent>
|
| 450 |
+
</Select>
|
| 451 |
+
</div>
|
| 452 |
+
|
| 453 |
+
{/* Zoom */}
|
| 454 |
+
<div className="space-y-2">
|
| 455 |
+
<Label>Zoom: {zoom.toFixed(1)}x</Label>
|
| 456 |
+
<div className="flex items-center gap-2">
|
| 457 |
+
<ZoomOut className="h-4 w-4 text-muted-foreground" />
|
| 458 |
+
<Slider
|
| 459 |
+
value={[zoom]}
|
| 460 |
+
onValueChange={([v]) => setZoom(v)}
|
| 461 |
+
min={0.5}
|
| 462 |
+
max={3}
|
| 463 |
+
step={0.1}
|
| 464 |
+
className="flex-1"
|
| 465 |
+
/>
|
| 466 |
+
<ZoomIn className="h-4 w-4 text-muted-foreground" />
|
| 467 |
+
</div>
|
| 468 |
+
</div>
|
| 469 |
+
|
| 470 |
+
{/* Rotation controls */}
|
| 471 |
+
<div className="space-y-2">
|
| 472 |
+
<Label>Rotation X: {rotation.x.toFixed(0)}°</Label>
|
| 473 |
+
<Slider
|
| 474 |
+
value={[rotation.x]}
|
| 475 |
+
onValueChange={([v]) => setRotation(prev => ({ ...prev, x: v }))}
|
| 476 |
+
min={-180}
|
| 477 |
+
max={180}
|
| 478 |
+
step={1}
|
| 479 |
+
/>
|
| 480 |
+
</div>
|
| 481 |
+
|
| 482 |
+
<div className="space-y-2">
|
| 483 |
+
<Label>Rotation Y: {rotation.y.toFixed(0)}°</Label>
|
| 484 |
+
<Slider
|
| 485 |
+
value={[rotation.y]}
|
| 486 |
+
onValueChange={([v]) => setRotation(prev => ({ ...prev, y: v }))}
|
| 487 |
+
min={-180}
|
| 488 |
+
max={180}
|
| 489 |
+
step={1}
|
| 490 |
+
/>
|
| 491 |
+
</div>
|
| 492 |
+
|
| 493 |
+
{/* Reset */}
|
| 494 |
+
<Button
|
| 495 |
+
variant="outline"
|
| 496 |
+
className="w-full"
|
| 497 |
+
onClick={() => {
|
| 498 |
+
setRotation({ x: 15, y: 30 })
|
| 499 |
+
setZoom(1)
|
| 500 |
+
}}
|
| 501 |
+
>
|
| 502 |
+
<RotateCcw className="h-4 w-4 mr-2" />
|
| 503 |
+
Reset View
|
| 504 |
+
</Button>
|
| 505 |
+
|
| 506 |
+
{/* Export */}
|
| 507 |
+
<div className="space-y-2">
|
| 508 |
+
<Label>Export Results</Label>
|
| 509 |
+
<div className="flex gap-2">
|
| 510 |
+
<Button
|
| 511 |
+
size="sm"
|
| 512 |
+
variant="outline"
|
| 513 |
+
onClick={() => exportToCSV(results)}
|
| 514 |
+
disabled={results.length === 0}
|
| 515 |
+
>
|
| 516 |
+
CSV
|
| 517 |
+
</Button>
|
| 518 |
+
<Button
|
| 519 |
+
size="sm"
|
| 520 |
+
variant="outline"
|
| 521 |
+
onClick={() => exportToJSON(results)}
|
| 522 |
+
disabled={results.length === 0}
|
| 523 |
+
>
|
| 524 |
+
JSON
|
| 525 |
+
</Button>
|
| 526 |
+
<Button
|
| 527 |
+
size="sm"
|
| 528 |
+
variant="outline"
|
| 529 |
+
onClick={() => exportToFASTA(results)}
|
| 530 |
+
disabled={results.length === 0}
|
| 531 |
+
>
|
| 532 |
+
FASTA
|
| 533 |
+
</Button>
|
| 534 |
+
</div>
|
| 535 |
+
</div>
|
| 536 |
+
|
| 537 |
+
{/* Stats */}
|
| 538 |
+
<div className="pt-4 border-t">
|
| 539 |
+
<div className="text-sm text-muted-foreground space-y-1">
|
| 540 |
+
<p>Points: {filteredPoints.length}</p>
|
| 541 |
+
<p>Results: {results.length}</p>
|
| 542 |
+
</div>
|
| 543 |
+
</div>
|
| 544 |
+
</CardContent>
|
| 545 |
+
</Card>
|
| 546 |
+
|
| 547 |
+
{/* 3D View */}
|
| 548 |
+
<div
|
| 549 |
+
className="lg:col-span-2"
|
| 550 |
+
onMouseDown={handleMouseDown}
|
| 551 |
+
onMouseMove={handleMouseMove}
|
| 552 |
+
onMouseUp={handleMouseUp}
|
| 553 |
+
onMouseLeave={handleMouseUp}
|
| 554 |
+
>
|
| 555 |
+
<Card>
|
| 556 |
+
<CardHeader className="pb-2">
|
| 557 |
+
<CardTitle className="text-lg">Embedding Space</CardTitle>
|
| 558 |
+
<CardDescription>
|
| 559 |
+
Drag to rotate • Click points for details
|
| 560 |
+
</CardDescription>
|
| 561 |
+
</CardHeader>
|
| 562 |
+
<CardContent className="p-4">
|
| 563 |
+
<Scatter3DCanvas
|
| 564 |
+
points={filteredPoints}
|
| 565 |
+
selectedPoint={selectedPoint}
|
| 566 |
+
onSelectPoint={handleSelectPoint}
|
| 567 |
+
rotation={rotation}
|
| 568 |
+
zoom={zoom}
|
| 569 |
+
/>
|
| 570 |
+
</CardContent>
|
| 571 |
+
</Card>
|
| 572 |
+
</div>
|
| 573 |
+
|
| 574 |
+
{/* Evidence Panel */}
|
| 575 |
+
<div className="lg:col-span-1">
|
| 576 |
+
<EvidencePanel result={selectedResult} />
|
| 577 |
+
</div>
|
| 578 |
+
</div>
|
| 579 |
+
</div>
|
| 580 |
+
)
|
| 581 |
+
}
|
ui/app/dashboard/workflow/page.tsx
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import {
|
| 5 |
+
ArrowRight,
|
| 6 |
+
Check,
|
| 7 |
+
ChevronDown,
|
| 8 |
+
ChevronRight,
|
| 9 |
+
Download,
|
| 10 |
+
Loader2,
|
| 11 |
+
Play,
|
| 12 |
+
Plus,
|
| 13 |
+
Settings,
|
| 14 |
+
Sparkles,
|
| 15 |
+
Trash2,
|
| 16 |
+
Upload,
|
| 17 |
+
X,
|
| 18 |
+
} from "lucide-react"
|
| 19 |
+
|
| 20 |
+
import { Button } from "@/components/ui/button"
|
| 21 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
| 22 |
+
import { Input } from "@/components/ui/input"
|
| 23 |
+
import { Label } from "@/components/ui/label"
|
| 24 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
| 25 |
+
import { Textarea } from "@/components/ui/textarea"
|
| 26 |
+
import { Badge } from "@/components/ui/badge"
|
| 27 |
+
import { ScrollArea } from "@/components/ui/scroll-area"
|
| 28 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
| 29 |
+
import { Slider } from "@/components/ui/slider"
|
| 30 |
+
import { Switch } from "@/components/ui/switch"
|
| 31 |
+
import { Progress } from "@/components/ui/progress"
|
| 32 |
+
|
| 33 |
+
// Types
|
| 34 |
+
interface WorkflowStep {
|
| 35 |
+
id: string
|
| 36 |
+
type: "generate" | "validate" | "rank"
|
| 37 |
+
name: string
|
| 38 |
+
config: Record<string, unknown>
|
| 39 |
+
status: "pending" | "running" | "completed" | "error"
|
| 40 |
+
result?: unknown
|
| 41 |
+
error?: string
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
interface WorkflowResult {
|
| 45 |
+
candidates: Array<{
|
| 46 |
+
smiles: string
|
| 47 |
+
name: string
|
| 48 |
+
validation: {
|
| 49 |
+
is_valid: boolean
|
| 50 |
+
checks: Record<string, boolean>
|
| 51 |
+
properties: Record<string, number>
|
| 52 |
+
}
|
| 53 |
+
score: number
|
| 54 |
+
}>
|
| 55 |
+
steps_completed: number
|
| 56 |
+
total_time_ms: number
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// Step Configuration Components
|
| 60 |
+
function GenerateStepConfig({
|
| 61 |
+
config,
|
| 62 |
+
onChange,
|
| 63 |
+
}: {
|
| 64 |
+
config: Record<string, unknown>
|
| 65 |
+
onChange: (config: Record<string, unknown>) => void
|
| 66 |
+
}) {
|
| 67 |
+
return (
|
| 68 |
+
<div className="space-y-4">
|
| 69 |
+
<div className="space-y-2">
|
| 70 |
+
<Label>Mode</Label>
|
| 71 |
+
<Select
|
| 72 |
+
value={(config.mode as string) || "text"}
|
| 73 |
+
onValueChange={(v) => onChange({ ...config, mode: v })}
|
| 74 |
+
>
|
| 75 |
+
<SelectTrigger>
|
| 76 |
+
<SelectValue />
|
| 77 |
+
</SelectTrigger>
|
| 78 |
+
<SelectContent>
|
| 79 |
+
<SelectItem value="text">Text-to-Molecule</SelectItem>
|
| 80 |
+
<SelectItem value="mutate">Mutation-Based</SelectItem>
|
| 81 |
+
<SelectItem value="scaffold">Scaffold-Based</SelectItem>
|
| 82 |
+
</SelectContent>
|
| 83 |
+
</Select>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div className="space-y-2">
|
| 87 |
+
<Label>Prompt / SMILES</Label>
|
| 88 |
+
<Textarea
|
| 89 |
+
placeholder={
|
| 90 |
+
config.mode === "text"
|
| 91 |
+
? "Describe the molecule you want to generate..."
|
| 92 |
+
: config.mode === "mutate"
|
| 93 |
+
? "Enter a SMILES string to mutate..."
|
| 94 |
+
: "Enter a scaffold SMILES..."
|
| 95 |
+
}
|
| 96 |
+
value={(config.prompt as string) || ""}
|
| 97 |
+
onChange={(e) => onChange({ ...config, prompt: e.target.value })}
|
| 98 |
+
className="h-24"
|
| 99 |
+
/>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<div className="space-y-2">
|
| 103 |
+
<Label>Number to Generate: {config.num_candidates || 5}</Label>
|
| 104 |
+
<Slider
|
| 105 |
+
value={[Number(config.num_candidates) || 5]}
|
| 106 |
+
onValueChange={([v]) => onChange({ ...config, num_candidates: v })}
|
| 107 |
+
min={1}
|
| 108 |
+
max={20}
|
| 109 |
+
step={1}
|
| 110 |
+
/>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
)
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function ValidateStepConfig({
|
| 117 |
+
config,
|
| 118 |
+
onChange,
|
| 119 |
+
}: {
|
| 120 |
+
config: Record<string, unknown>
|
| 121 |
+
onChange: (config: Record<string, unknown>) => void
|
| 122 |
+
}) {
|
| 123 |
+
const checks = (config.checks as string[]) || ["lipinski", "admet", "qed", "alerts"]
|
| 124 |
+
|
| 125 |
+
const toggleCheck = (check: string) => {
|
| 126 |
+
const newChecks = checks.includes(check)
|
| 127 |
+
? checks.filter((c) => c !== check)
|
| 128 |
+
: [...checks, check]
|
| 129 |
+
onChange({ ...config, checks: newChecks })
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
return (
|
| 133 |
+
<div className="space-y-4">
|
| 134 |
+
<Label>Validation Checks</Label>
|
| 135 |
+
<div className="grid grid-cols-2 gap-2">
|
| 136 |
+
{[
|
| 137 |
+
{ id: "lipinski", label: "Lipinski Rule of 5" },
|
| 138 |
+
{ id: "admet", label: "ADMET Properties" },
|
| 139 |
+
{ id: "qed", label: "QED Score" },
|
| 140 |
+
{ id: "alerts", label: "Structural Alerts" },
|
| 141 |
+
].map((check) => (
|
| 142 |
+
<div
|
| 143 |
+
key={check.id}
|
| 144 |
+
className={`flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-colors ${
|
| 145 |
+
checks.includes(check.id)
|
| 146 |
+
? "border-primary bg-primary/10"
|
| 147 |
+
: "border-border hover:border-primary/50"
|
| 148 |
+
}`}
|
| 149 |
+
onClick={() => toggleCheck(check.id)}
|
| 150 |
+
>
|
| 151 |
+
<div
|
| 152 |
+
className={`w-4 h-4 rounded border flex items-center justify-center ${
|
| 153 |
+
checks.includes(check.id) ? "bg-primary border-primary" : "border-muted-foreground"
|
| 154 |
+
}`}
|
| 155 |
+
>
|
| 156 |
+
{checks.includes(check.id) && <Check className="h-3 w-3 text-primary-foreground" />}
|
| 157 |
+
</div>
|
| 158 |
+
<span className="text-sm">{check.label}</span>
|
| 159 |
+
</div>
|
| 160 |
+
))}
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div className="flex items-center justify-between">
|
| 164 |
+
<Label>Strict Mode</Label>
|
| 165 |
+
<Switch
|
| 166 |
+
checked={Boolean(config.strict)}
|
| 167 |
+
onCheckedChange={(v) => onChange({ ...config, strict: v })}
|
| 168 |
+
/>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
)
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function RankStepConfig({
|
| 175 |
+
config,
|
| 176 |
+
onChange,
|
| 177 |
+
}: {
|
| 178 |
+
config: Record<string, unknown>
|
| 179 |
+
onChange: (config: Record<string, unknown>) => void
|
| 180 |
+
}) {
|
| 181 |
+
const weights = (config.weights as Record<string, number>) || {
|
| 182 |
+
qed: 0.3,
|
| 183 |
+
validity: 0.3,
|
| 184 |
+
mw: 0.2,
|
| 185 |
+
logp: 0.2,
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
const updateWeight = (key: string, value: number) => {
|
| 189 |
+
onChange({ ...config, weights: { ...weights, [key]: value } })
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
return (
|
| 193 |
+
<div className="space-y-4">
|
| 194 |
+
<Label>Ranking Weights</Label>
|
| 195 |
+
|
| 196 |
+
{Object.entries({
|
| 197 |
+
qed: "QED Score",
|
| 198 |
+
validity: "Validity",
|
| 199 |
+
mw: "Molecular Weight",
|
| 200 |
+
logp: "LogP",
|
| 201 |
+
}).map(([key, label]) => (
|
| 202 |
+
<div key={key} className="space-y-1">
|
| 203 |
+
<div className="flex justify-between text-sm">
|
| 204 |
+
<span>{label}</span>
|
| 205 |
+
<span className="text-muted-foreground">{(weights[key] || 0).toFixed(2)}</span>
|
| 206 |
+
</div>
|
| 207 |
+
<Slider
|
| 208 |
+
value={[weights[key] || 0]}
|
| 209 |
+
onValueChange={([v]) => updateWeight(key, v)}
|
| 210 |
+
min={0}
|
| 211 |
+
max={1}
|
| 212 |
+
step={0.05}
|
| 213 |
+
/>
|
| 214 |
+
</div>
|
| 215 |
+
))}
|
| 216 |
+
|
| 217 |
+
<div className="space-y-2">
|
| 218 |
+
<Label>Top K: {config.top_k || 5}</Label>
|
| 219 |
+
<Slider
|
| 220 |
+
value={[Number(config.top_k) || 5]}
|
| 221 |
+
onValueChange={([v]) => onChange({ ...config, top_k: v })}
|
| 222 |
+
min={1}
|
| 223 |
+
max={20}
|
| 224 |
+
step={1}
|
| 225 |
+
/>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
)
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// Step Card Component
|
| 232 |
+
function StepCard({
|
| 233 |
+
step,
|
| 234 |
+
index,
|
| 235 |
+
onUpdate,
|
| 236 |
+
onRemove,
|
| 237 |
+
isLast,
|
| 238 |
+
}: {
|
| 239 |
+
step: WorkflowStep
|
| 240 |
+
index: number
|
| 241 |
+
onUpdate: (step: WorkflowStep) => void
|
| 242 |
+
onRemove: () => void
|
| 243 |
+
isLast: boolean
|
| 244 |
+
}) {
|
| 245 |
+
const [isExpanded, setIsExpanded] = React.useState(true)
|
| 246 |
+
|
| 247 |
+
const getStepIcon = () => {
|
| 248 |
+
switch (step.type) {
|
| 249 |
+
case "generate":
|
| 250 |
+
return <Sparkles className="h-4 w-4" />
|
| 251 |
+
case "validate":
|
| 252 |
+
return <Check className="h-4 w-4" />
|
| 253 |
+
case "rank":
|
| 254 |
+
return <Settings className="h-4 w-4" />
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
const getStatusBadge = () => {
|
| 259 |
+
switch (step.status) {
|
| 260 |
+
case "pending":
|
| 261 |
+
return <Badge variant="outline">Pending</Badge>
|
| 262 |
+
case "running":
|
| 263 |
+
return <Badge className="bg-blue-500">Running</Badge>
|
| 264 |
+
case "completed":
|
| 265 |
+
return <Badge className="bg-green-500">Completed</Badge>
|
| 266 |
+
case "error":
|
| 267 |
+
return <Badge variant="destructive">Error</Badge>
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
return (
|
| 272 |
+
<div className="relative">
|
| 273 |
+
<Card className={step.status === "running" ? "border-blue-500 shadow-lg" : ""}>
|
| 274 |
+
<CardHeader className="py-3">
|
| 275 |
+
<div className="flex items-center justify-between">
|
| 276 |
+
<div className="flex items-center gap-3">
|
| 277 |
+
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary">
|
| 278 |
+
{index + 1}
|
| 279 |
+
</div>
|
| 280 |
+
<div className="flex items-center gap-2">
|
| 281 |
+
{getStepIcon()}
|
| 282 |
+
<span className="font-medium">{step.name}</span>
|
| 283 |
+
</div>
|
| 284 |
+
{getStatusBadge()}
|
| 285 |
+
</div>
|
| 286 |
+
<div className="flex items-center gap-2">
|
| 287 |
+
<Button
|
| 288 |
+
size="sm"
|
| 289 |
+
variant="ghost"
|
| 290 |
+
onClick={() => setIsExpanded(!isExpanded)}
|
| 291 |
+
>
|
| 292 |
+
{isExpanded ? (
|
| 293 |
+
<ChevronDown className="h-4 w-4" />
|
| 294 |
+
) : (
|
| 295 |
+
<ChevronRight className="h-4 w-4" />
|
| 296 |
+
)}
|
| 297 |
+
</Button>
|
| 298 |
+
<Button
|
| 299 |
+
size="sm"
|
| 300 |
+
variant="ghost"
|
| 301 |
+
onClick={onRemove}
|
| 302 |
+
className="text-destructive hover:text-destructive"
|
| 303 |
+
>
|
| 304 |
+
<Trash2 className="h-4 w-4" />
|
| 305 |
+
</Button>
|
| 306 |
+
</div>
|
| 307 |
+
</div>
|
| 308 |
+
</CardHeader>
|
| 309 |
+
|
| 310 |
+
{isExpanded && (
|
| 311 |
+
<CardContent className="pt-0">
|
| 312 |
+
{step.type === "generate" && (
|
| 313 |
+
<GenerateStepConfig
|
| 314 |
+
config={step.config}
|
| 315 |
+
onChange={(config) => onUpdate({ ...step, config })}
|
| 316 |
+
/>
|
| 317 |
+
)}
|
| 318 |
+
{step.type === "validate" && (
|
| 319 |
+
<ValidateStepConfig
|
| 320 |
+
config={step.config}
|
| 321 |
+
onChange={(config) => onUpdate({ ...step, config })}
|
| 322 |
+
/>
|
| 323 |
+
)}
|
| 324 |
+
{step.type === "rank" && (
|
| 325 |
+
<RankStepConfig
|
| 326 |
+
config={step.config}
|
| 327 |
+
onChange={(config) => onUpdate({ ...step, config })}
|
| 328 |
+
/>
|
| 329 |
+
)}
|
| 330 |
+
|
| 331 |
+
{step.error && (
|
| 332 |
+
<div className="mt-4 p-3 bg-destructive/10 text-destructive rounded-lg text-sm">
|
| 333 |
+
{step.error}
|
| 334 |
+
</div>
|
| 335 |
+
)}
|
| 336 |
+
</CardContent>
|
| 337 |
+
)}
|
| 338 |
+
</Card>
|
| 339 |
+
|
| 340 |
+
{/* Arrow connector */}
|
| 341 |
+
{!isLast && (
|
| 342 |
+
<div className="flex justify-center py-2">
|
| 343 |
+
<ArrowRight className="h-6 w-6 text-muted-foreground" />
|
| 344 |
+
</div>
|
| 345 |
+
)}
|
| 346 |
+
</div>
|
| 347 |
+
)
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
// Results Display
|
| 351 |
+
function WorkflowResults({ result }: { result: WorkflowResult | null }) {
|
| 352 |
+
if (!result) return null
|
| 353 |
+
|
| 354 |
+
return (
|
| 355 |
+
<Card>
|
| 356 |
+
<CardHeader>
|
| 357 |
+
<div className="flex items-center justify-between">
|
| 358 |
+
<CardTitle className="text-lg">Results</CardTitle>
|
| 359 |
+
<Badge variant="outline">
|
| 360 |
+
{result.total_time_ms.toFixed(0)}ms
|
| 361 |
+
</Badge>
|
| 362 |
+
</div>
|
| 363 |
+
<CardDescription>
|
| 364 |
+
{result.candidates.length} candidates generated • {result.steps_completed} steps completed
|
| 365 |
+
</CardDescription>
|
| 366 |
+
</CardHeader>
|
| 367 |
+
<CardContent>
|
| 368 |
+
<ScrollArea className="h-[300px]">
|
| 369 |
+
<div className="space-y-3">
|
| 370 |
+
{result.candidates.map((candidate, idx) => (
|
| 371 |
+
<div
|
| 372 |
+
key={idx}
|
| 373 |
+
className="p-3 rounded-lg border bg-card"
|
| 374 |
+
>
|
| 375 |
+
<div className="flex items-start justify-between">
|
| 376 |
+
<div className="space-y-1">
|
| 377 |
+
<div className="flex items-center gap-2">
|
| 378 |
+
<span className="font-medium">{candidate.name}</span>
|
| 379 |
+
<Badge variant={candidate.validation.is_valid ? "default" : "destructive"}>
|
| 380 |
+
{candidate.validation.is_valid ? "Valid" : "Invalid"}
|
| 381 |
+
</Badge>
|
| 382 |
+
</div>
|
| 383 |
+
<code className="text-xs text-muted-foreground block max-w-md truncate">
|
| 384 |
+
{candidate.smiles}
|
| 385 |
+
</code>
|
| 386 |
+
</div>
|
| 387 |
+
<div className="text-right">
|
| 388 |
+
<div className="text-lg font-bold">{candidate.score.toFixed(3)}</div>
|
| 389 |
+
<span className="text-xs text-muted-foreground">Score</span>
|
| 390 |
+
</div>
|
| 391 |
+
</div>
|
| 392 |
+
|
| 393 |
+
{/* Properties */}
|
| 394 |
+
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
|
| 395 |
+
{Object.entries(candidate.validation.properties).map(([key, value]) => (
|
| 396 |
+
<span key={key}>
|
| 397 |
+
{key}: {typeof value === "number" ? value.toFixed(2) : value}
|
| 398 |
+
</span>
|
| 399 |
+
))}
|
| 400 |
+
</div>
|
| 401 |
+
</div>
|
| 402 |
+
))}
|
| 403 |
+
</div>
|
| 404 |
+
</ScrollArea>
|
| 405 |
+
</CardContent>
|
| 406 |
+
</Card>
|
| 407 |
+
)
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
// Main Workflow Builder Page
|
| 411 |
+
export default function WorkflowBuilderPage() {
|
| 412 |
+
const [steps, setSteps] = React.useState<WorkflowStep[]>([
|
| 413 |
+
{
|
| 414 |
+
id: "gen-1",
|
| 415 |
+
type: "generate",
|
| 416 |
+
name: "Generate Molecules",
|
| 417 |
+
config: { mode: "text", prompt: "", num_candidates: 5 },
|
| 418 |
+
status: "pending",
|
| 419 |
+
},
|
| 420 |
+
{
|
| 421 |
+
id: "val-1",
|
| 422 |
+
type: "validate",
|
| 423 |
+
name: "Validate Candidates",
|
| 424 |
+
config: { checks: ["lipinski", "admet", "qed", "alerts"], strict: false },
|
| 425 |
+
status: "pending",
|
| 426 |
+
},
|
| 427 |
+
{
|
| 428 |
+
id: "rank-1",
|
| 429 |
+
type: "rank",
|
| 430 |
+
name: "Rank & Select",
|
| 431 |
+
config: { weights: { qed: 0.3, validity: 0.3, mw: 0.2, logp: 0.2 }, top_k: 5 },
|
| 432 |
+
status: "pending",
|
| 433 |
+
},
|
| 434 |
+
])
|
| 435 |
+
const [isRunning, setIsRunning] = React.useState(false)
|
| 436 |
+
const [progress, setProgress] = React.useState(0)
|
| 437 |
+
const [result, setResult] = React.useState<WorkflowResult | null>(null)
|
| 438 |
+
const [workflowName, setWorkflowName] = React.useState("My Discovery Workflow")
|
| 439 |
+
|
| 440 |
+
// Add a new step
|
| 441 |
+
const addStep = (type: WorkflowStep["type"]) => {
|
| 442 |
+
const id = `${type}-${Date.now()}`
|
| 443 |
+
const newStep: WorkflowStep = {
|
| 444 |
+
id,
|
| 445 |
+
type,
|
| 446 |
+
name: type === "generate" ? "Generate Molecules" : type === "validate" ? "Validate Candidates" : "Rank & Select",
|
| 447 |
+
config: type === "generate"
|
| 448 |
+
? { mode: "text", prompt: "", num_candidates: 5 }
|
| 449 |
+
: type === "validate"
|
| 450 |
+
? { checks: ["lipinski", "admet", "qed", "alerts"], strict: false }
|
| 451 |
+
: { weights: { qed: 0.3, validity: 0.3, mw: 0.2, logp: 0.2 }, top_k: 5 },
|
| 452 |
+
status: "pending",
|
| 453 |
+
}
|
| 454 |
+
setSteps([...steps, newStep])
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
// Update a step
|
| 458 |
+
const updateStep = (updatedStep: WorkflowStep) => {
|
| 459 |
+
setSteps(steps.map((s) => (s.id === updatedStep.id ? updatedStep : s)))
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
// Remove a step
|
| 463 |
+
const removeStep = (id: string) => {
|
| 464 |
+
setSteps(steps.filter((s) => s.id !== id))
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
// Run the workflow
|
| 468 |
+
const runWorkflow = async () => {
|
| 469 |
+
setIsRunning(true)
|
| 470 |
+
setProgress(0)
|
| 471 |
+
setResult(null)
|
| 472 |
+
|
| 473 |
+
// Reset all step statuses
|
| 474 |
+
setSteps(steps.map((s) => ({ ...s, status: "pending", result: undefined, error: undefined })))
|
| 475 |
+
|
| 476 |
+
try {
|
| 477 |
+
// Build workflow config from steps
|
| 478 |
+
const generateStep = steps.find((s) => s.type === "generate")
|
| 479 |
+
const validateStep = steps.find((s) => s.type === "validate")
|
| 480 |
+
const rankStep = steps.find((s) => s.type === "rank")
|
| 481 |
+
|
| 482 |
+
if (!generateStep) {
|
| 483 |
+
throw new Error("Workflow must include a generate step")
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
// Mark generate as running
|
| 487 |
+
setSteps((prev) =>
|
| 488 |
+
prev.map((s) => (s.id === generateStep.id ? { ...s, status: "running" } : s))
|
| 489 |
+
)
|
| 490 |
+
setProgress(10)
|
| 491 |
+
|
| 492 |
+
// Call the workflow API
|
| 493 |
+
const response = await fetch("/api/agents/workflow", {
|
| 494 |
+
method: "POST",
|
| 495 |
+
headers: { "Content-Type": "application/json" },
|
| 496 |
+
body: JSON.stringify({
|
| 497 |
+
query: generateStep.config.prompt || "drug-like molecule",
|
| 498 |
+
num_candidates: generateStep.config.num_candidates || 5,
|
| 499 |
+
top_k: rankStep?.config.top_k || 5,
|
| 500 |
+
}),
|
| 501 |
+
})
|
| 502 |
+
|
| 503 |
+
// Simulate step progression
|
| 504 |
+
setProgress(30)
|
| 505 |
+
setSteps((prev) =>
|
| 506 |
+
prev.map((s) =>
|
| 507 |
+
s.id === generateStep.id
|
| 508 |
+
? { ...s, status: "completed" }
|
| 509 |
+
: s.type === "validate"
|
| 510 |
+
? { ...s, status: "running" }
|
| 511 |
+
: s
|
| 512 |
+
)
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
await new Promise((r) => setTimeout(r, 500))
|
| 516 |
+
setProgress(60)
|
| 517 |
+
|
| 518 |
+
if (validateStep) {
|
| 519 |
+
setSteps((prev) =>
|
| 520 |
+
prev.map((s) =>
|
| 521 |
+
s.id === validateStep.id
|
| 522 |
+
? { ...s, status: "completed" }
|
| 523 |
+
: s.type === "rank"
|
| 524 |
+
? { ...s, status: "running" }
|
| 525 |
+
: s
|
| 526 |
+
)
|
| 527 |
+
)
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
await new Promise((r) => setTimeout(r, 500))
|
| 531 |
+
setProgress(90)
|
| 532 |
+
|
| 533 |
+
const data = await response.json()
|
| 534 |
+
|
| 535 |
+
if (!response.ok) {
|
| 536 |
+
throw new Error(data.detail || "Workflow failed")
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
// Mark all as completed
|
| 540 |
+
setSteps((prev) => prev.map((s) => ({ ...s, status: "completed" })))
|
| 541 |
+
setProgress(100)
|
| 542 |
+
|
| 543 |
+
// Transform API result to WorkflowResult
|
| 544 |
+
// API returns: success, status, steps_completed, total_steps, execution_time_ms, top_candidates, all_outputs, errors
|
| 545 |
+
const workflowResult: WorkflowResult = {
|
| 546 |
+
candidates: (data.top_candidates || []).map((c: any) => ({
|
| 547 |
+
smiles: c.smiles || '',
|
| 548 |
+
name: c.name || `Candidate ${c.rank || 0}`,
|
| 549 |
+
validation: c.validation || { is_valid: true, checks: {}, properties: {} },
|
| 550 |
+
score: c.score || 0,
|
| 551 |
+
})),
|
| 552 |
+
steps_completed: data.steps_completed || steps.length,
|
| 553 |
+
total_time_ms: data.execution_time_ms || 0,
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
setResult(workflowResult)
|
| 557 |
+
} catch (err) {
|
| 558 |
+
console.error("Workflow error:", err)
|
| 559 |
+
setSteps((prev) =>
|
| 560 |
+
prev.map((s) =>
|
| 561 |
+
s.status === "running"
|
| 562 |
+
? { ...s, status: "error", error: String(err) }
|
| 563 |
+
: s
|
| 564 |
+
)
|
| 565 |
+
)
|
| 566 |
+
} finally {
|
| 567 |
+
setIsRunning(false)
|
| 568 |
+
}
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
// Export workflow config
|
| 572 |
+
const exportWorkflow = () => {
|
| 573 |
+
const config = {
|
| 574 |
+
name: workflowName,
|
| 575 |
+
steps: steps.map((s) => ({
|
| 576 |
+
type: s.type,
|
| 577 |
+
name: s.name,
|
| 578 |
+
config: s.config,
|
| 579 |
+
})),
|
| 580 |
+
}
|
| 581 |
+
const json = JSON.stringify(config, null, 2)
|
| 582 |
+
const blob = new Blob([json], { type: "application/json" })
|
| 583 |
+
const url = URL.createObjectURL(blob)
|
| 584 |
+
const a = document.createElement("a")
|
| 585 |
+
a.href = url
|
| 586 |
+
a.download = `${workflowName.replace(/\s+/g, "_").toLowerCase()}.json`
|
| 587 |
+
a.click()
|
| 588 |
+
URL.revokeObjectURL(url)
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
// Import workflow config
|
| 592 |
+
const importWorkflow = () => {
|
| 593 |
+
const input = document.createElement("input")
|
| 594 |
+
input.type = "file"
|
| 595 |
+
input.accept = ".json"
|
| 596 |
+
input.onchange = async (e) => {
|
| 597 |
+
const file = (e.target as HTMLInputElement).files?.[0]
|
| 598 |
+
if (!file) return
|
| 599 |
+
|
| 600 |
+
const text = await file.text()
|
| 601 |
+
try {
|
| 602 |
+
const config = JSON.parse(text)
|
| 603 |
+
setWorkflowName(config.name || "Imported Workflow")
|
| 604 |
+
setSteps(
|
| 605 |
+
config.steps.map((s: Record<string, unknown>, idx: number) => ({
|
| 606 |
+
id: `${s.type}-${idx}`,
|
| 607 |
+
type: s.type,
|
| 608 |
+
name: s.name,
|
| 609 |
+
config: s.config,
|
| 610 |
+
status: "pending",
|
| 611 |
+
}))
|
| 612 |
+
)
|
| 613 |
+
} catch (err) {
|
| 614 |
+
console.error("Failed to import workflow:", err)
|
| 615 |
+
}
|
| 616 |
+
}
|
| 617 |
+
input.click()
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
return (
|
| 621 |
+
<div className="container mx-auto p-6 space-y-6">
|
| 622 |
+
{/* Header */}
|
| 623 |
+
<div className="flex items-center justify-between">
|
| 624 |
+
<div className="space-y-1">
|
| 625 |
+
<h1 className="text-3xl font-bold tracking-tight">Workflow Builder</h1>
|
| 626 |
+
<p className="text-muted-foreground">
|
| 627 |
+
Design and execute drug discovery pipelines with visual configuration
|
| 628 |
+
</p>
|
| 629 |
+
</div>
|
| 630 |
+
<div className="flex items-center gap-2">
|
| 631 |
+
<Button variant="outline" onClick={importWorkflow}>
|
| 632 |
+
<Upload className="h-4 w-4 mr-2" />
|
| 633 |
+
Import
|
| 634 |
+
</Button>
|
| 635 |
+
<Button variant="outline" onClick={exportWorkflow}>
|
| 636 |
+
<Download className="h-4 w-4 mr-2" />
|
| 637 |
+
Export
|
| 638 |
+
</Button>
|
| 639 |
+
<Button onClick={runWorkflow} disabled={isRunning || steps.length === 0}>
|
| 640 |
+
{isRunning ? (
|
| 641 |
+
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
| 642 |
+
) : (
|
| 643 |
+
<Play className="h-4 w-4 mr-2" />
|
| 644 |
+
)}
|
| 645 |
+
Run Workflow
|
| 646 |
+
</Button>
|
| 647 |
+
</div>
|
| 648 |
+
</div>
|
| 649 |
+
|
| 650 |
+
{/* Workflow Name */}
|
| 651 |
+
<Card>
|
| 652 |
+
<CardContent className="pt-6">
|
| 653 |
+
<div className="flex items-center gap-4">
|
| 654 |
+
<Label className="whitespace-nowrap">Workflow Name:</Label>
|
| 655 |
+
<Input
|
| 656 |
+
value={workflowName}
|
| 657 |
+
onChange={(e) => setWorkflowName(e.target.value)}
|
| 658 |
+
className="max-w-md"
|
| 659 |
+
/>
|
| 660 |
+
</div>
|
| 661 |
+
</CardContent>
|
| 662 |
+
</Card>
|
| 663 |
+
|
| 664 |
+
{/* Progress */}
|
| 665 |
+
{isRunning && (
|
| 666 |
+
<Card>
|
| 667 |
+
<CardContent className="pt-6">
|
| 668 |
+
<div className="space-y-2">
|
| 669 |
+
<div className="flex justify-between text-sm">
|
| 670 |
+
<span>Running workflow...</span>
|
| 671 |
+
<span>{progress}%</span>
|
| 672 |
+
</div>
|
| 673 |
+
<Progress value={progress} />
|
| 674 |
+
</div>
|
| 675 |
+
</CardContent>
|
| 676 |
+
</Card>
|
| 677 |
+
)}
|
| 678 |
+
|
| 679 |
+
{/* Main Content */}
|
| 680 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 681 |
+
{/* Pipeline Editor */}
|
| 682 |
+
<div className="lg:col-span-2 space-y-4">
|
| 683 |
+
<Card>
|
| 684 |
+
<CardHeader>
|
| 685 |
+
<CardTitle>Pipeline Steps</CardTitle>
|
| 686 |
+
<CardDescription>
|
| 687 |
+
Configure each step of your discovery workflow
|
| 688 |
+
</CardDescription>
|
| 689 |
+
</CardHeader>
|
| 690 |
+
<CardContent className="space-y-4">
|
| 691 |
+
{steps.length === 0 ? (
|
| 692 |
+
<div className="text-center py-8 text-muted-foreground">
|
| 693 |
+
No steps added yet. Click "Add Step" to get started.
|
| 694 |
+
</div>
|
| 695 |
+
) : (
|
| 696 |
+
steps.map((step, idx) => (
|
| 697 |
+
<StepCard
|
| 698 |
+
key={step.id}
|
| 699 |
+
step={step}
|
| 700 |
+
index={idx}
|
| 701 |
+
onUpdate={updateStep}
|
| 702 |
+
onRemove={() => removeStep(step.id)}
|
| 703 |
+
isLast={idx === steps.length - 1}
|
| 704 |
+
/>
|
| 705 |
+
))
|
| 706 |
+
)}
|
| 707 |
+
|
| 708 |
+
{/* Add Step Buttons */}
|
| 709 |
+
<div className="flex gap-2 pt-4 border-t">
|
| 710 |
+
<Button
|
| 711 |
+
variant="outline"
|
| 712 |
+
size="sm"
|
| 713 |
+
onClick={() => addStep("generate")}
|
| 714 |
+
>
|
| 715 |
+
<Plus className="h-4 w-4 mr-1" />
|
| 716 |
+
Add Generate
|
| 717 |
+
</Button>
|
| 718 |
+
<Button
|
| 719 |
+
variant="outline"
|
| 720 |
+
size="sm"
|
| 721 |
+
onClick={() => addStep("validate")}
|
| 722 |
+
>
|
| 723 |
+
<Plus className="h-4 w-4 mr-1" />
|
| 724 |
+
Add Validate
|
| 725 |
+
</Button>
|
| 726 |
+
<Button
|
| 727 |
+
variant="outline"
|
| 728 |
+
size="sm"
|
| 729 |
+
onClick={() => addStep("rank")}
|
| 730 |
+
>
|
| 731 |
+
<Plus className="h-4 w-4 mr-1" />
|
| 732 |
+
Add Rank
|
| 733 |
+
</Button>
|
| 734 |
+
</div>
|
| 735 |
+
</CardContent>
|
| 736 |
+
</Card>
|
| 737 |
+
</div>
|
| 738 |
+
|
| 739 |
+
{/* Results Panel */}
|
| 740 |
+
<div className="lg:col-span-1">
|
| 741 |
+
<WorkflowResults result={result} />
|
| 742 |
+
|
| 743 |
+
{!result && (
|
| 744 |
+
<Card>
|
| 745 |
+
<CardHeader>
|
| 746 |
+
<CardTitle className="text-lg">Results</CardTitle>
|
| 747 |
+
<CardDescription>
|
| 748 |
+
Run the workflow to see results
|
| 749 |
+
</CardDescription>
|
| 750 |
+
</CardHeader>
|
| 751 |
+
<CardContent>
|
| 752 |
+
<div className="text-center py-8 text-muted-foreground">
|
| 753 |
+
Configure your pipeline and click "Run Workflow" to execute
|
| 754 |
+
</div>
|
| 755 |
+
</CardContent>
|
| 756 |
+
</Card>
|
| 757 |
+
)}
|
| 758 |
+
</div>
|
| 759 |
+
</div>
|
| 760 |
+
</div>
|
| 761 |
+
)
|
| 762 |
+
}
|
ui/app/discovery/page.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|
| 11 |
import { Tabs, TabsContent,TabsList, TabsTrigger } from "@/components/ui/tabs"
|
| 12 |
import { Textarea } from "@/components/ui/textarea"
|
| 13 |
|
| 14 |
-
const API_BASE = "http://localhost:
|
| 15 |
|
| 16 |
interface SearchResult {
|
| 17 |
id: string;
|
|
|
|
| 11 |
import { Tabs, TabsContent,TabsList, TabsTrigger } from "@/components/ui/tabs"
|
| 12 |
import { Textarea } from "@/components/ui/textarea"
|
| 13 |
|
| 14 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
| 15 |
|
| 16 |
interface SearchResult {
|
| 17 |
id: string;
|
ui/app/layout.tsx
CHANGED
|
@@ -1,43 +1,32 @@
|
|
| 1 |
-
import type React from "react";
|
| 2 |
-
|
| 3 |
-
import { Inter, Space_Mono } from "next/font/google";
|
| 4 |
-
import type { Metadata, Viewport } from "next";
|
| 5 |
-
|
| 6 |
import "./globals.css";
|
| 7 |
-
import { ProjectStateProvider } from "@/hooks/use-project-state";
|
| 8 |
-
import { cn } from "@/lib/utils";
|
| 9 |
|
| 10 |
-
import type {
|
| 11 |
-
import {
|
| 12 |
-
|
| 13 |
-
import { Toaster } from "@/components/ui/sonner";
|
| 14 |
import { ThemeProvider } from "next-themes";
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
subsets: ["latin"],
|
| 18 |
-
variable: "--font-inter",
|
| 19 |
-
display: "swap",
|
| 20 |
});
|
| 21 |
|
| 22 |
-
const
|
|
|
|
| 23 |
subsets: ["latin"],
|
| 24 |
-
variable: "--font-space-mono",
|
| 25 |
-
display: "swap",
|
| 26 |
-
weight: ["400", "700"],
|
| 27 |
});
|
| 28 |
|
| 29 |
export const viewport: Viewport = {
|
| 30 |
-
width: "device-width",
|
| 31 |
-
initialScale: 1,
|
| 32 |
-
maximumScale: 5,
|
| 33 |
-
userScalable: true,
|
| 34 |
-
viewportFit: "cover",
|
| 35 |
-
interactiveWidget: "resizes-content",
|
| 36 |
-
colorScheme: "light dark",
|
| 37 |
themeColor: [
|
| 38 |
-
{ media: "(prefers-color-scheme: light)", color: "
|
| 39 |
-
{ media: "(prefers-color-scheme: dark)", color: "
|
| 40 |
],
|
|
|
|
|
|
|
| 41 |
};
|
| 42 |
|
| 43 |
export const metadata: Metadata = {
|
|
@@ -51,54 +40,33 @@ export const metadata: Metadata = {
|
|
| 51 |
creator: "BioFlow",
|
| 52 |
};
|
| 53 |
|
| 54 |
-
export default function RootLayout({
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
description: API_CONFIG.description,
|
| 60 |
-
applicationCategory: "ScienceApplication",
|
| 61 |
-
operatingSystem: "Web",
|
| 62 |
-
offers: {
|
| 63 |
-
"@type": "Offer",
|
| 64 |
-
price: "0",
|
| 65 |
-
priceCurrency: "USD",
|
| 66 |
-
},
|
| 67 |
-
author: {
|
| 68 |
-
"@type": "Organization",
|
| 69 |
-
name: API_CONFIG.author,
|
| 70 |
-
},
|
| 71 |
-
};
|
| 72 |
-
|
| 73 |
-
const safeJsonLd = JSON.stringify(jsonLd).replace(/</g, "\\u003c");
|
| 74 |
-
|
| 75 |
return (
|
| 76 |
-
<html lang="
|
| 77 |
-
<head>
|
| 78 |
-
<script
|
| 79 |
-
type="application/ld+json"
|
| 80 |
-
dangerouslySetInnerHTML={{ __html: safeJsonLd }}
|
| 81 |
-
/>
|
| 82 |
-
</head>
|
| 83 |
<body
|
| 84 |
-
className={
|
| 85 |
-
INTER.variable,
|
| 86 |
-
SPACE_MONO.variable,
|
| 87 |
-
"min-h-screen bg-background font-sans text-foreground antialiased"
|
| 88 |
-
)}
|
| 89 |
>
|
| 90 |
-
<
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
</body>
|
| 103 |
</html>
|
| 104 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import "./globals.css";
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
import type { Metadata, Viewport } from "next";
|
| 4 |
+
import { Geist, Geist_Mono } from "next/font/google";
|
|
|
|
|
|
|
| 5 |
import { ThemeProvider } from "next-themes";
|
| 6 |
|
| 7 |
+
import {
|
| 8 |
+
SidebarInset,
|
| 9 |
+
SidebarProvider,
|
| 10 |
+
} from "@/components/animate-ui/components/radix/sidebar";
|
| 11 |
+
import { AppSidebar } from "@/components/sidebar";
|
| 12 |
+
|
| 13 |
+
const geistSans = Geist({
|
| 14 |
+
variable: "--font-geist-sans",
|
| 15 |
subsets: ["latin"],
|
|
|
|
|
|
|
| 16 |
});
|
| 17 |
|
| 18 |
+
const geistMono = Geist_Mono({
|
| 19 |
+
variable: "--font-geist-mono",
|
| 20 |
subsets: ["latin"],
|
|
|
|
|
|
|
|
|
|
| 21 |
});
|
| 22 |
|
| 23 |
export const viewport: Viewport = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
themeColor: [
|
| 25 |
+
{ media: "(prefers-color-scheme: light)", color: "white" },
|
| 26 |
+
{ media: "(prefers-color-scheme: dark)", color: "#0C0E14" },
|
| 27 |
],
|
| 28 |
+
width: "device-width",
|
| 29 |
+
initialScale: 1,
|
| 30 |
};
|
| 31 |
|
| 32 |
export const metadata: Metadata = {
|
|
|
|
| 40 |
creator: "BioFlow",
|
| 41 |
};
|
| 42 |
|
| 43 |
+
export default function RootLayout({
|
| 44 |
+
children,
|
| 45 |
+
}: Readonly<{
|
| 46 |
+
children: React.ReactNode;
|
| 47 |
+
}>) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
return (
|
| 49 |
+
<html lang="en" suppressHydrationWarning>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
<body
|
| 51 |
+
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
>
|
| 53 |
+
<ThemeProvider
|
| 54 |
+
attribute="class"
|
| 55 |
+
defaultTheme="system"
|
| 56 |
+
enableSystem
|
| 57 |
+
disableTransitionOnChange
|
| 58 |
+
>
|
| 59 |
+
<SidebarProvider>
|
| 60 |
+
<AppSidebar />
|
| 61 |
+
<SidebarInset>
|
| 62 |
+
<main className="flex-1 overflow-y-auto bg-background p-8">
|
| 63 |
+
<div className="mx-auto max-w-7xl">
|
| 64 |
+
{children}
|
| 65 |
+
</div>
|
| 66 |
+
</main>
|
| 67 |
+
</SidebarInset>
|
| 68 |
+
</SidebarProvider>
|
| 69 |
+
</ThemeProvider>
|
| 70 |
</body>
|
| 71 |
</html>
|
| 72 |
);
|
ui/app/page.tsx
CHANGED
|
@@ -1,5 +1,170 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
}
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
|
| 3 |
+
import { ArrowUp, Database, FileText, Search, Sparkles, Zap, Beaker, Dna, BookOpen } from "lucide-react"
|
| 4 |
+
import Link from "next/link"
|
| 5 |
+
|
| 6 |
+
import { SectionHeader } from "@/components/page-header"
|
| 7 |
+
import { Badge } from "@/components/ui/badge"
|
| 8 |
+
import { Button } from "@/components/ui/button"
|
| 9 |
+
import { Card, CardContent } from "@/components/ui/card"
|
| 10 |
+
import { Separator } from "@/components/ui/separator"
|
| 11 |
+
|
| 12 |
+
export default function Home() {
|
| 13 |
+
return (
|
| 14 |
+
<div className="space-y-8 animate-in fade-in duration-500">
|
| 15 |
+
{/* Hero Section */}
|
| 16 |
+
<div className="flex flex-col lg:flex-row gap-6">
|
| 17 |
+
<div className="flex-1 rounded-2xl bg-gradient-to-br from-primary/10 via-background to-background p-8 border">
|
| 18 |
+
<Badge variant="secondary" className="mb-4">New • BioFlow 2.0</Badge>
|
| 19 |
+
<h1 className="text-4xl font-bold tracking-tight mb-4">AI-Powered Drug Discovery</h1>
|
| 20 |
+
<p className="text-lg text-muted-foreground mb-6 max-w-xl">
|
| 21 |
+
Run discovery pipelines, predict binding, and surface evidence in one streamlined workspace.
|
| 22 |
+
</p>
|
| 23 |
+
<div className="flex gap-2 mb-6">
|
| 24 |
+
<Badge variant="outline" className="bg-primary/5 border-primary/20 text-primary">Model-aware search</Badge>
|
| 25 |
+
<Badge variant="outline" className="bg-green-500/10 border-green-500/20 text-green-700 dark:text-green-400">Evidence-linked</Badge>
|
| 26 |
+
<Badge variant="outline" className="bg-amber-500/10 border-amber-500/20 text-amber-700 dark:text-amber-400">Fast iteration</Badge>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<div className="flex gap-4">
|
| 30 |
+
<Link href="/dashboard/discovery">
|
| 31 |
+
<Button size="lg" className="font-semibold">
|
| 32 |
+
Start Discovery
|
| 33 |
+
</Button>
|
| 34 |
+
</Link>
|
| 35 |
+
<Link href="/dashboard/explorer">
|
| 36 |
+
<Button size="lg" variant="outline">
|
| 37 |
+
Explore Data
|
| 38 |
+
</Button>
|
| 39 |
+
</Link>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<div className="lg:w-[350px]">
|
| 44 |
+
<Card className="h-full">
|
| 45 |
+
<CardContent className="p-6 flex flex-col justify-between h-full">
|
| 46 |
+
<div>
|
| 47 |
+
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">Today</div>
|
| 48 |
+
<div className="text-4xl font-bold mb-2">156 Discoveries</div>
|
| 49 |
+
<div className="text-sm text-green-600 font-medium flex items-center gap-1">
|
| 50 |
+
<ArrowUp className="h-4 w-4" />
|
| 51 |
+
+12% vs last week
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<Separator className="my-4" />
|
| 56 |
+
|
| 57 |
+
<div className="space-y-2">
|
| 58 |
+
<div className="flex items-center justify-between text-sm">
|
| 59 |
+
<span className="flex items-center gap-2">
|
| 60 |
+
<span className="h-2 w-2 rounded-full bg-primary"></span>
|
| 61 |
+
Discovery
|
| 62 |
+
</span>
|
| 63 |
+
<span className="font-mono font-medium">64</span>
|
| 64 |
+
</div>
|
| 65 |
+
<div className="flex items-center justify-between text-sm">
|
| 66 |
+
<span className="flex items-center gap-2">
|
| 67 |
+
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
| 68 |
+
Prediction
|
| 69 |
+
</span>
|
| 70 |
+
<span className="font-mono font-medium">42</span>
|
| 71 |
+
</div>
|
| 72 |
+
<div className="flex items-center justify-between text-sm">
|
| 73 |
+
<span className="flex items-center gap-2">
|
| 74 |
+
<span className="h-2 w-2 rounded-full bg-amber-500"></span>
|
| 75 |
+
Evidence
|
| 76 |
+
</span>
|
| 77 |
+
<span className="font-mono font-medium">50</span>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</CardContent>
|
| 81 |
+
</Card>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{/* Metrics Row */}
|
| 86 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 87 |
+
{[
|
| 88 |
+
{ label: "Molecules", value: "12.5M", icon: <Beaker className="h-5 w-5 text-blue-500" />, change: "+2.3%" },
|
| 89 |
+
{ label: "Proteins", value: "847K", icon: <Dna className="h-5 w-5 text-cyan-500" />, change: "+1.8%" },
|
| 90 |
+
{ label: "Papers", value: "1.2M", icon: <BookOpen className="h-5 w-5 text-emerald-500" />, change: "+5.2%" },
|
| 91 |
+
{ label: "Discoveries", value: "156", icon: <Sparkles className="h-5 w-5 text-amber-500" />, change: "+12%" }
|
| 92 |
+
].map((metric, i) => (
|
| 93 |
+
<Card key={i}>
|
| 94 |
+
<CardContent className="p-6">
|
| 95 |
+
<div className="flex justify-between items-start mb-2">
|
| 96 |
+
<div className="text-sm font-medium text-muted-foreground">{metric.label}</div>
|
| 97 |
+
<div className="text-lg">{metric.icon}</div>
|
| 98 |
+
</div>
|
| 99 |
+
<div className="text-2xl font-bold mb-1">{metric.value}</div>
|
| 100 |
+
<div className="text-xs font-medium flex items-center gap-1 text-green-500">
|
| 101 |
+
<ArrowUp className="h-3 w-3" />
|
| 102 |
+
{metric.change}
|
| 103 |
+
</div>
|
| 104 |
+
</CardContent>
|
| 105 |
+
</Card>
|
| 106 |
+
))}
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
{/* Quick Actions */}
|
| 110 |
+
<div className="pt-4">
|
| 111 |
+
<SectionHeader title="Quick Actions" icon={<Zap className="h-5 w-5 text-amber-500" />} />
|
| 112 |
+
|
| 113 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
| 114 |
+
<Link href="/discovery" className="block">
|
| 115 |
+
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
| 116 |
+
<CardContent className="p-6 flex flex-col items-center text-center gap-3">
|
| 117 |
+
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
| 118 |
+
<Search className="h-5 w-5" />
|
| 119 |
+
</div>
|
| 120 |
+
<div>
|
| 121 |
+
<div className="font-semibold">New Discovery</div>
|
| 122 |
+
<div className="text-sm text-muted-foreground">Start a pipeline</div>
|
| 123 |
+
</div>
|
| 124 |
+
</CardContent>
|
| 125 |
+
</Card>
|
| 126 |
+
</Link>
|
| 127 |
+
<Link href="/explorer" className="block">
|
| 128 |
+
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
| 129 |
+
<CardContent className="p-6 flex flex-col items-center text-center gap-3">
|
| 130 |
+
<div className="h-10 w-10 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-500">
|
| 131 |
+
<Database className="h-5 w-5" />
|
| 132 |
+
</div>
|
| 133 |
+
<div>
|
| 134 |
+
<div className="font-semibold">Browse Data</div>
|
| 135 |
+
<div className="text-sm text-muted-foreground">Explore datasets</div>
|
| 136 |
+
</div>
|
| 137 |
+
</CardContent>
|
| 138 |
+
</Card>
|
| 139 |
+
</Link>
|
| 140 |
+
<Link href="/data" className="block">
|
| 141 |
+
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
| 142 |
+
<CardContent className="p-6 flex flex-col items-center text-center gap-3">
|
| 143 |
+
<div className="h-10 w-10 rounded-full bg-purple-500/10 flex items-center justify-center text-purple-500">
|
| 144 |
+
<FileText className="h-5 w-5" />
|
| 145 |
+
</div>
|
| 146 |
+
<div>
|
| 147 |
+
<div className="font-semibold">Training</div>
|
| 148 |
+
<div className="text-sm text-muted-foreground">Train new models</div>
|
| 149 |
+
</div>
|
| 150 |
+
</CardContent>
|
| 151 |
+
</Card>
|
| 152 |
+
</Link>
|
| 153 |
+
<Link href="/settings" className="block">
|
| 154 |
+
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
| 155 |
+
<CardContent className="p-6 flex flex-col items-center text-center gap-3">
|
| 156 |
+
<div className="h-10 w-10 rounded-full bg-slate-500/10 flex items-center justify-center text-slate-500">
|
| 157 |
+
<Sparkles className="h-5 w-5" />
|
| 158 |
+
</div>
|
| 159 |
+
<div>
|
| 160 |
+
<div className="font-semibold">View Insights</div>
|
| 161 |
+
<div className="text-sm text-muted-foreground">Check predictions</div>
|
| 162 |
+
</div>
|
| 163 |
+
</CardContent>
|
| 164 |
+
</Card>
|
| 165 |
+
</Link>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
)
|
| 170 |
}
|
ui/components/animate-ui/components/radix/sidebar.tsx
CHANGED
|
@@ -245,7 +245,7 @@ function Sidebar({
|
|
| 245 |
<div
|
| 246 |
data-slot="sidebar-gap"
|
| 247 |
className={cn(
|
| 248 |
-
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-
|
| 249 |
'group-data-[collapsible=offcanvas]:w-0',
|
| 250 |
'group-data-[side=right]:rotate-180',
|
| 251 |
variant === 'floating' || variant === 'inset'
|
|
@@ -256,7 +256,7 @@ function Sidebar({
|
|
| 256 |
<div
|
| 257 |
data-slot="sidebar-container"
|
| 258 |
className={cn(
|
| 259 |
-
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-
|
| 260 |
side === 'left'
|
| 261 |
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
| 262 |
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
|
|
|
| 245 |
<div
|
| 246 |
data-slot="sidebar-gap"
|
| 247 |
className={cn(
|
| 248 |
+
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-300 ease-out',
|
| 249 |
'group-data-[collapsible=offcanvas]:w-0',
|
| 250 |
'group-data-[side=right]:rotate-180',
|
| 251 |
variant === 'floating' || variant === 'inset'
|
|
|
|
| 256 |
<div
|
| 257 |
data-slot="sidebar-container"
|
| 258 |
className={cn(
|
| 259 |
+
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-300 ease-out md:flex',
|
| 260 |
side === 'left'
|
| 261 |
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
| 262 |
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
ui/components/sidebar.tsx
CHANGED
|
@@ -59,105 +59,105 @@ import { useIsMobile } from '@/hooks/use-mobile';
|
|
| 59 |
const navMain = [
|
| 60 |
{
|
| 61 |
title: 'Home',
|
| 62 |
-
url: '/',
|
| 63 |
icon: Home,
|
| 64 |
isActive: true,
|
| 65 |
},
|
| 66 |
{
|
| 67 |
title: 'Visualization',
|
| 68 |
-
url: '/molecules-2d',
|
| 69 |
icon: FlaskConical,
|
| 70 |
items: [
|
| 71 |
{
|
| 72 |
title: 'Molecules 2D',
|
| 73 |
-
url: '/molecules-2d',
|
| 74 |
},
|
| 75 |
{
|
| 76 |
title: 'Molecules 3D',
|
| 77 |
-
url: '/molecules-3d',
|
| 78 |
},
|
| 79 |
{
|
| 80 |
title: 'Proteins 3D',
|
| 81 |
-
url: '/proteins-3d',
|
| 82 |
},
|
| 83 |
],
|
| 84 |
},
|
| 85 |
{
|
| 86 |
title: 'Discovery',
|
| 87 |
-
url: '/discovery',
|
| 88 |
icon: Microscope,
|
| 89 |
items: [
|
| 90 |
{
|
| 91 |
title: 'Drug Discovery',
|
| 92 |
-
url: '/discovery',
|
| 93 |
},
|
| 94 |
{
|
| 95 |
title: 'Molecule Search',
|
| 96 |
-
url: '/discovery#search',
|
| 97 |
},
|
| 98 |
],
|
| 99 |
},
|
| 100 |
{
|
| 101 |
title: 'Explorer',
|
| 102 |
-
url: '/explorer',
|
| 103 |
icon: Dna,
|
| 104 |
items: [
|
| 105 |
{
|
| 106 |
title: 'Embeddings',
|
| 107 |
-
url: '/explorer',
|
| 108 |
},
|
| 109 |
{
|
| 110 |
title: '3D Visualization',
|
| 111 |
-
url: '/visualization',
|
| 112 |
},
|
| 113 |
{
|
| 114 |
title: 'Predictions',
|
| 115 |
-
url: '/explorer#predictions',
|
| 116 |
},
|
| 117 |
],
|
| 118 |
},
|
| 119 |
{
|
| 120 |
title: 'Workflows',
|
| 121 |
-
url: '/workflow',
|
| 122 |
icon: Sparkles,
|
| 123 |
items: [
|
| 124 |
{
|
| 125 |
title: 'Builder',
|
| 126 |
-
url: '/workflow',
|
| 127 |
},
|
| 128 |
{
|
| 129 |
title: 'Templates',
|
| 130 |
-
url: '/workflow#templates',
|
| 131 |
},
|
| 132 |
],
|
| 133 |
},
|
| 134 |
{
|
| 135 |
title: 'Data',
|
| 136 |
-
url: '/data',
|
| 137 |
icon: BarChart2,
|
| 138 |
items: [
|
| 139 |
{
|
| 140 |
title: 'Datasets',
|
| 141 |
-
url: '/data',
|
| 142 |
},
|
| 143 |
{
|
| 144 |
title: 'Analytics',
|
| 145 |
-
url: '/data#analytics',
|
| 146 |
},
|
| 147 |
],
|
| 148 |
},
|
| 149 |
{
|
| 150 |
title: 'Settings',
|
| 151 |
-
url: '/settings',
|
| 152 |
icon: Settings,
|
| 153 |
items: [
|
| 154 |
{
|
| 155 |
title: 'General',
|
| 156 |
-
url: '/settings',
|
| 157 |
},
|
| 158 |
{
|
| 159 |
title: 'Models',
|
| 160 |
-
url: '/settings#models',
|
| 161 |
},
|
| 162 |
],
|
| 163 |
},
|
|
@@ -180,7 +180,7 @@ export function AppSidebar() {
|
|
| 180 |
<SidebarMenu>
|
| 181 |
<SidebarMenuItem>
|
| 182 |
<SidebarMenuButton size="lg" asChild>
|
| 183 |
-
<Link href="/">
|
| 184 |
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
| 185 |
<Dna className="size-4" />
|
| 186 |
</div>
|
|
|
|
| 59 |
const navMain = [
|
| 60 |
{
|
| 61 |
title: 'Home',
|
| 62 |
+
url: '/dashboard',
|
| 63 |
icon: Home,
|
| 64 |
isActive: true,
|
| 65 |
},
|
| 66 |
{
|
| 67 |
title: 'Visualization',
|
| 68 |
+
url: '/dashboard/molecules-2d',
|
| 69 |
icon: FlaskConical,
|
| 70 |
items: [
|
| 71 |
{
|
| 72 |
title: 'Molecules 2D',
|
| 73 |
+
url: '/dashboard/molecules-2d',
|
| 74 |
},
|
| 75 |
{
|
| 76 |
title: 'Molecules 3D',
|
| 77 |
+
url: '/dashboard/molecules-3d',
|
| 78 |
},
|
| 79 |
{
|
| 80 |
title: 'Proteins 3D',
|
| 81 |
+
url: '/dashboard/proteins-3d',
|
| 82 |
},
|
| 83 |
],
|
| 84 |
},
|
| 85 |
{
|
| 86 |
title: 'Discovery',
|
| 87 |
+
url: '/dashboard/discovery',
|
| 88 |
icon: Microscope,
|
| 89 |
items: [
|
| 90 |
{
|
| 91 |
title: 'Drug Discovery',
|
| 92 |
+
url: '/dashboard/discovery',
|
| 93 |
},
|
| 94 |
{
|
| 95 |
title: 'Molecule Search',
|
| 96 |
+
url: '/dashboard/discovery#search',
|
| 97 |
},
|
| 98 |
],
|
| 99 |
},
|
| 100 |
{
|
| 101 |
title: 'Explorer',
|
| 102 |
+
url: '/dashboard/explorer',
|
| 103 |
icon: Dna,
|
| 104 |
items: [
|
| 105 |
{
|
| 106 |
title: 'Embeddings',
|
| 107 |
+
url: '/dashboard/explorer',
|
| 108 |
},
|
| 109 |
{
|
| 110 |
title: '3D Visualization',
|
| 111 |
+
url: '/dashboard/visualization',
|
| 112 |
},
|
| 113 |
{
|
| 114 |
title: 'Predictions',
|
| 115 |
+
url: '/dashboard/explorer#predictions',
|
| 116 |
},
|
| 117 |
],
|
| 118 |
},
|
| 119 |
{
|
| 120 |
title: 'Workflows',
|
| 121 |
+
url: '/dashboard/workflow',
|
| 122 |
icon: Sparkles,
|
| 123 |
items: [
|
| 124 |
{
|
| 125 |
title: 'Builder',
|
| 126 |
+
url: '/dashboard/workflow',
|
| 127 |
},
|
| 128 |
{
|
| 129 |
title: 'Templates',
|
| 130 |
+
url: '/dashboard/workflow#templates',
|
| 131 |
},
|
| 132 |
],
|
| 133 |
},
|
| 134 |
{
|
| 135 |
title: 'Data',
|
| 136 |
+
url: '/dashboard/data',
|
| 137 |
icon: BarChart2,
|
| 138 |
items: [
|
| 139 |
{
|
| 140 |
title: 'Datasets',
|
| 141 |
+
url: '/dashboard/data',
|
| 142 |
},
|
| 143 |
{
|
| 144 |
title: 'Analytics',
|
| 145 |
+
url: '/dashboard/data#analytics',
|
| 146 |
},
|
| 147 |
],
|
| 148 |
},
|
| 149 |
{
|
| 150 |
title: 'Settings',
|
| 151 |
+
url: '/dashboard/settings',
|
| 152 |
icon: Settings,
|
| 153 |
items: [
|
| 154 |
{
|
| 155 |
title: 'General',
|
| 156 |
+
url: '/dashboard/settings',
|
| 157 |
},
|
| 158 |
{
|
| 159 |
title: 'Models',
|
| 160 |
+
url: '/dashboard/settings#models',
|
| 161 |
},
|
| 162 |
],
|
| 163 |
},
|
|
|
|
| 180 |
<SidebarMenu>
|
| 181 |
<SidebarMenuItem>
|
| 182 |
<SidebarMenuButton size="lg" asChild>
|
| 183 |
+
<Link href="/dashboard">
|
| 184 |
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
| 185 |
<Dna className="size-4" />
|
| 186 |
</div>
|
ui/lib/data-service.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { DataResponse } from "@/types/data";
|
| 2 |
|
| 3 |
-
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:
|
| 4 |
|
| 5 |
export async function getData(): Promise<DataResponse> {
|
| 6 |
try {
|
|
|
|
| 1 |
import { DataResponse } from "@/types/data";
|
| 2 |
|
| 3 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
| 4 |
|
| 5 |
export async function getData(): Promise<DataResponse> {
|
| 6 |
try {
|