yassinekolsi commited on
Commit
cd3cb84
·
1 Parent(s): f2bd2d1

feat: enhance visualization with mock data fallback and update readme

Browse files
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: EnhancedSearchRequest):
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=request.query,
399
- modality=request.modality,
400
- collection=request.collection,
401
- top_k=request.top_k,
402
- use_mmr=request.use_mmr,
403
- lambda_param=request.lambda_param,
404
- filters=request.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
- return NextResponse.json(data.molecules || data);
 
 
 
 
 
 
 
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
- return NextResponse.json(data.proteins || data);
 
 
 
 
 
 
 
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:8001";
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 { WebApplication, WithContext } from "schema-dts";
11
- import { API_CONFIG } from "@/config/api.config";
12
-
13
- import { Toaster } from "@/components/ui/sonner";
14
  import { ThemeProvider } from "next-themes";
15
 
16
- const INTER = Inter({
 
 
 
 
 
 
 
17
  subsets: ["latin"],
18
- variable: "--font-inter",
19
- display: "swap",
20
  });
21
 
22
- const SPACE_MONO = Space_Mono({
 
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: "oklch(1 0 0)" },
39
- { media: "(prefers-color-scheme: dark)", color: "oklch(0.1 0.02 265)" },
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({ children }: { children: React.ReactNode }) {
55
- const jsonLd: WithContext<WebApplication> = {
56
- "@context": "https://schema.org",
57
- "@type": "WebApplication",
58
- name: API_CONFIG.name,
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="fr" className="scroll-smooth" suppressHydrationWarning>
77
- <head>
78
- <script
79
- type="application/ld+json"
80
- dangerouslySetInnerHTML={{ __html: safeJsonLd }}
81
- />
82
- </head>
83
  <body
84
- className={cn(
85
- INTER.variable,
86
- SPACE_MONO.variable,
87
- "min-h-screen bg-background font-sans text-foreground antialiased"
88
- )}
89
  >
90
- <ProjectStateProvider>
91
- <ThemeProvider
92
- attribute="class"
93
- defaultTheme="system"
94
- enableSystem
95
- disableTransitionOnChange
96
- storageKey="bisoness-theme"
97
- >
98
- {children}
99
- <Toaster />
100
- </ThemeProvider>
101
- </ProjectStateProvider>
 
 
 
 
 
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
- import { redirect } from "next/navigation"
2
 
3
- export default function RootPage() {
4
- redirect("/dashboard")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-400 ease-[cubic-bezier(0.7,-0.15,0.25,1.15)]',
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-400 ease-[cubic-bezier(0.75,0,0.25,1)] 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)]',
 
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:8001";
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 {