1
0

add: frontent for new ML: ml_panel

This commit is contained in:
alikia2x (寒寒) 2025-12-10 18:39:37 +08:00
parent 7dbef68cdc
commit a4ec4ca01c
WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS.
GPG Key ID: 56209E0CCD8420C6
31 changed files with 2271 additions and 328 deletions

View File

@ -4,6 +4,7 @@
"": {
"name": "cvsa",
"dependencies": {
"@tanstack/react-query": "^5.90.12",
"arg": "^5.0.2",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
@ -84,6 +85,12 @@
"name": "ml_panel",
"version": "0.0.0",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -927,6 +934,10 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.17", "", { "dependencies": { "@tailwindcss/node": "4.1.17", "@tailwindcss/oxide": "4.1.17", "tailwindcss": "4.1.17" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],

View File

@ -0,0 +1 @@
*.toml

View File

@ -9,7 +9,17 @@ from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from config_loader import config_loader
from models import DatasetBuildRequest, DatasetBuildResponse, TaskStatus, TaskStatusResponse, TaskListResponse
from models import (
DatasetBuildRequest,
DatasetBuildResponse,
TaskStatus,
TaskStatusResponse,
TaskListResponse,
SamplingRequest,
SamplingResponse,
DatasetCreateRequest,
DatasetCreateResponse
)
from dataset_service import DatasetBuilder
from logger_config import get_logger
@ -135,39 +145,6 @@ async def get_dataset_endpoint(dataset_id: str):
}
@router.get("/datasets")
async def list_datasets():
"""List all built datasets"""
if not dataset_builder:
raise HTTPException(status_code=503, detail="Dataset builder not available")
datasets = []
for dataset_id, dataset_info in dataset_builder.dataset_storage.items():
if "error" not in dataset_info:
datasets.append({
"dataset_id": dataset_id,
"description": dataset_info.get("description"),
"stats": dataset_info["stats"],
"created_at": dataset_info["created_at"]
})
return {"datasets": datasets}
@router.delete("/dataset/{dataset_id}")
async def delete_dataset_endpoint(dataset_id: str):
"""Delete a built dataset"""
if not dataset_builder:
raise HTTPException(status_code=503, detail="Dataset builder not available")
if dataset_builder.delete_dataset(dataset_id):
return {"message": f"Dataset {dataset_id} deleted successfully"}
else:
raise HTTPException(status_code=404, detail="Dataset not found")
@router.get("/datasets")
async def list_datasets_endpoint():
"""List all built datasets"""
@ -188,6 +165,19 @@ async def list_datasets_endpoint():
return {"datasets": datasets_with_description}
@router.delete("/dataset/{dataset_id}")
async def delete_dataset_endpoint(dataset_id: str):
"""Delete a built dataset"""
if not dataset_builder:
raise HTTPException(status_code=503, detail="Dataset builder not available")
if dataset_builder.delete_dataset(dataset_id):
return {"message": f"Dataset {dataset_id} deleted successfully"}
else:
raise HTTPException(status_code=404, detail="Dataset not found")
@router.get("/datasets/stats")
async def get_dataset_stats_endpoint():
"""Get overall statistics about stored datasets"""
@ -314,4 +304,98 @@ async def cleanup_tasks_endpoint(max_age_hours: int = 24):
raise HTTPException(status_code=503, detail="Dataset builder not available")
cleaned_count = await dataset_builder.cleanup_completed_tasks(max_age_hours)
return {"message": f"Cleaned up {cleaned_count} tasks older than {max_age_hours} hours"}
return {"message": f"Cleaned up {cleaned_count} tasks older than {max_age_hours} hours"}
# Sampling Endpoints
@router.post("/dataset/sample", response_model=SamplingResponse)
async def sample_dataset_endpoint(request: SamplingRequest):
"""Sample AIDs based on strategy"""
if not dataset_builder:
raise HTTPException(status_code=503, detail="Dataset builder not available")
try:
# Get AIDs based on strategy
aid_list = await dataset_builder.db_manager.get_aids_by_strategy(
strategy=request.strategy,
limit=request.limit,
)
# Get statistics
total_available = await dataset_builder.db_manager.get_all_aids_count()
return SamplingResponse(
strategy=request.strategy,
total_available=total_available,
sampled_count=len(aid_list),
aid_list=aid_list,
filters_applied={
"limit": request.limit
},
sampling_info={
"strategy_description": _get_strategy_description(request.strategy),
"sample_ratio": len(aid_list) / total_available if total_available > 0 else 0
}
)
except Exception as e:
logger.error(f"Sampling failed: {str(e)}")
raise HTTPException(status_code=400, detail=f"Sampling failed: {str(e)}")
@router.post("/dataset/create-with-sampling", response_model=DatasetCreateResponse)
async def create_dataset_with_sampling_endpoint(request: DatasetCreateRequest):
"""Create dataset using sampling strategy"""
if not dataset_builder:
raise HTTPException(status_code=503, detail="Dataset builder not available")
# Validate embedding model
if request.embedding_model not in config_loader.get_embedding_models():
raise HTTPException(status_code=400, detail=f"Invalid embedding model: {request.embedding_model}")
import uuid
dataset_id = str(uuid.uuid4())
try:
# First sample the AIDs
sampling_response = await sample_dataset_endpoint(request.sampling)
aid_list = sampling_response.aid_list
if not aid_list:
raise HTTPException(status_code=400, detail="No AIDs found matching the sampling criteria")
# Start task-based dataset building with sampled AIDs
task_id = await dataset_builder.start_dataset_build_task(
dataset_id,
aid_list,
request.embedding_model,
request.force_regenerate,
request.description
)
return DatasetCreateResponse(
dataset_id=dataset_id,
sampling_response=sampling_response,
task_id=task_id,
total_records=len(aid_list),
status="started",
message=f"Dataset building started with task ID: {task_id}",
description=request.description
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Dataset creation with sampling failed: {str(e)}")
raise HTTPException(status_code=500, detail=f"Dataset creation failed: {str(e)}")
def _get_strategy_description(strategy: str) -> str:
"""Get description for sampling strategy"""
descriptions = {
"all": "All labeled videos in the database",
"random": "Randomly sampled labeled videos"
}
return descriptions.get(strategy, "Unknown sampling strategy")

View File

@ -2,6 +2,7 @@
Database connection and operations for ML training service
"""
from collections import defaultdict
import os
import hashlib
from typing import List, Dict, Optional, Any
@ -18,6 +19,7 @@ logger = get_logger(__name__)
# Database configuration
DATABASE_URL = os.getenv("DATABASE_URL")
class DatabaseManager:
def __init__(self):
self.pool: Optional[asyncpg.Pool] = None
@ -26,7 +28,7 @@ class DatabaseManager:
"""Initialize database connection pool"""
try:
self.pool = await asyncpg.create_pool(DATABASE_URL, min_size=5, max_size=20)
logger.info("Database connection pool initialized")
except Exception as e:
logger.error(f"Failed to connect to database: {e}")
@ -37,7 +39,7 @@ class DatabaseManager:
if self.pool:
await self.pool.close()
logger.info("Database connection pool closed")
@property
def is_connected(self) -> bool:
"""Check if database connection pool is initialized"""
@ -129,12 +131,24 @@ class DatabaseManager:
result = {}
for row in rows:
checksum = row["data_checksum"]
# Convert vector strings to lists if they exist
vec_2048 = self._parse_vector_string(row["vec_2048"]) if row["vec_2048"] else None
vec_1536 = self._parse_vector_string(row["vec_1536"]) if row["vec_1536"] else None
vec_1024 = self._parse_vector_string(row["vec_1024"]) if row["vec_1024"] else None
vec_2048 = (
self._parse_vector_string(row["vec_2048"])
if row["vec_2048"]
else None
)
vec_1536 = (
self._parse_vector_string(row["vec_1536"])
if row["vec_1536"]
else None
)
vec_1024 = (
self._parse_vector_string(row["vec_1024"])
if row["vec_1024"]
else None
)
result[checksum] = {
"checksum": checksum,
"vec_2048": vec_2048,
@ -144,36 +158,45 @@ class DatabaseManager:
}
return result
def _parse_vector_string(self, vector_str: str) -> List[float]:
"""Parse vector string format '[1.0,2.0,3.0]' back to list"""
if not vector_str:
return []
try:
# Remove brackets and split by comma
vector_str = vector_str.strip()
if vector_str.startswith('[') and vector_str.endswith(']'):
if vector_str.startswith("[") and vector_str.endswith("]"):
vector_str = vector_str[1:-1]
return [float(x.strip()) for x in vector_str.split(',') if x.strip()]
return [float(x.strip()) for x in vector_str.split(",") if x.strip()]
except Exception as e:
logger.warning(f"Failed to parse vector string '{vector_str}': {e}")
return []
async def insert_embeddings(self, embeddings_data: List[Dict[str, Any]]) -> None:
"""Batch insert embeddings into database"""
"""Batch insert embeddings into database (Optimized)"""
if not embeddings_data:
return
batches = defaultdict(list)
now = datetime.now()
for data in embeddings_data:
vector_str = str(data["vector"])
# "[" + ",".join(map(str, data["vector"])) + "]"
dim = data["dimensions"]
batches[dim].append(
(data["model_name"], dim, data["checksum"], vector_str, now)
)
async with self.pool.acquire() as conn:
async with conn.transaction():
for data in embeddings_data:
# Determine which vector column to use based on dimensions
vec_column = f"vec_{data['dimensions']}"
# Convert vector list to string format for PostgreSQL
vector_str = "[" + ",".join(map(str, data["vector"])) + "]"
for dim, values in batches.items():
vec_column = f"vec_{dim}"
query = f"""
INSERT INTO internal.embeddings
@ -182,103 +205,70 @@ class DatabaseManager:
ON CONFLICT (model_name, dimensions, data_checksum) DO NOTHING
"""
await conn.execute(
query,
data["model_name"],
data["dimensions"],
data["checksum"],
vector_str,
datetime.now(),
)
await conn.executemany(query, values)
async def get_final_dataset(
self, aid_list: List[int], model_name: str
) -> List[Dict[str, Any]]:
"""Get final dataset with embeddings and labels"""
if not aid_list:
return []
# Sampling Methods
async def get_all_aids(self) -> List[int]:
"""Get all available AIDs from labeled data (internal.video_type_label)"""
async with self.pool.acquire() as conn:
query = "SELECT DISTINCT aid FROM internal.video_type_label WHERE aid IS NOT NULL"
rows = await conn.fetch(query)
return [int(row["aid"]) for row in rows]
async def get_all_aids_count(self) -> List[int]:
"""Get all available AIDs from labeled data (internal.video_type_label)"""
async with self.pool.acquire() as conn:
query = "SELECT COUNT(DISTINCT aid) FROM internal.video_type_label WHERE aid IS NOT NULL"
rows = await conn.fetch(query)
return rows[0]["count"]
# Get video metadata
metadata = await self.get_video_metadata(aid_list)
async def get_aids_by_strategy(
self, strategy: str, limit: Optional[int] = None
) -> List[int]:
"""Get AIDs based on sampling strategy"""
if strategy == "all":
return await self.get_all_aids()
elif strategy == "random":
return await self.get_random_aids(limit or 1000)
else:
raise ValueError(f"Unknown sampling strategy: {strategy}")
# Get user labels (latest per user)
labels = await self.get_user_labels(aid_list)
async def get_random_aids(
self, limit: int
) -> List[int]:
"""Get random AIDs from labeled data only"""
async with self.pool.acquire() as conn:
query = "SELECT aid FROM internal.video_type_label ORDER BY RANDOM() LIMIT $1"
rows = await conn.fetch(query, limit)
aids = [int(row["aid"]) for row in rows]
# deduplication
return list(set(aids))
# Prepare text data for embedding
text_data = []
aid_to_text = {}
for aid in aid_list:
if aid in metadata:
# Combine title, description, and tags for embedding
text_parts = [
metadata[aid]["title"],
metadata[aid]["description"],
metadata[aid]["tags"],
]
combined_text = " ".join(filter(None, text_parts))
# Create checksum for deduplication
checksum = hashlib.md5(combined_text.encode("utf-8")).hexdigest()
text_data.append(
{"aid": aid, "text": combined_text, "checksum": checksum}
)
aid_to_text[checksum] = aid
# Get checksums = [ existing embeddings
checks = [item["checksum"] for item in text_data]
existing_embeddings = await self.get_existing_embeddings(checks)
# ums, model_name Prepare final dataset
dataset = []
for item in text_data:
aid = item["aid"]
checksum = item["checksum"]
# Get embedding vector
embedding_vector = None
if checksum in existing_embeddings:
# Use existing embedding
emb_data = existing_embeddings[checksum]
if emb_data["vec_1536"]:
embedding_vector = emb_data["vec_1536"]
elif emb_data["vec_2048"]:
embedding_vector = emb_data["vec_2048"]
elif emb_data["vec_1024"]:
embedding_vector = emb_data["vec_1024"]
# Get labels for this aid
aid_labels = labels.get(aid, [])
# Determine final label using consensus (majority vote)
if aid_labels:
positive_votes = sum(1 for lbl in aid_labels if lbl["label"])
final_label = positive_votes > len(aid_labels) / 2
else:
final_label = None # No labels available
# Check for inconsistent labels
inconsistent = len(aid_labels) > 1 and (
sum(1 for lbl in aid_labels if lbl["label"]) != 0
and sum(1 for lbl in aid_labels if lbl["label"]) != len(aid_labels)
async def get_sampling_stats(self) -> Dict[str, Any]:
"""Get statistics about available labeled data for sampling"""
async with self.pool.acquire() as conn:
# Total labeled videos
total_labeled_query = (
"SELECT COUNT(DISTINCT aid) as count FROM internal.video_type_label"
)
total_labeled_result = await conn.fetchrow(total_labeled_query)
total_labeled_videos = total_labeled_result["count"]
if embedding_vector and final_label is not None:
dataset.append(
{
"aid": aid,
"embedding": embedding_vector,
"label": final_label,
"metadata": metadata.get(aid, {}),
"user_labels": aid_labels,
"inconsistent": inconsistent,
"text_checksum": checksum,
}
)
# Positive and negative labels
positive_query = "SELECT COUNT(DISTINCT aid) as count FROM internal.video_type_label WHERE label = true"
negative_query = "SELECT COUNT(DISTINCT aid) as count FROM internal.video_type_label WHERE label = false"
return dataset
positive_result = await conn.fetchrow(positive_query)
negative_result = await conn.fetchrow(negative_query)
positive_labels = positive_result["count"]
negative_labels = negative_result["count"]
return {
"total_labeled_videos": total_labeled_videos,
"positive_labels": positive_labels,
"negative_labels": negative_labels,
}
# Global database manager instance

View File

@ -570,6 +570,8 @@ class DatasetBuilder:
"""List all datasets with their basic information"""
datasets = []
self._load_all_datasets()
for dataset_id, dataset_info in self.dataset_storage.items():
if "error" not in dataset_info:
datasets.append({

View File

@ -2,9 +2,51 @@
Data models for dataset building functionality
"""
from typing import List, Optional, Dict, Any
from typing import List, Optional, Dict, Any, Literal
from pydantic import BaseModel, Field
from datetime import datetime
from enum import Enum
class TaskStatus(str, Enum):
"""Task status enumeration"""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class SamplingStrategy(str, Enum):
"""Sampling strategy enumeration"""
ALL = "all" # All labeled AIDs
RANDOM = "random" # Random sampling from labeled data
class TaskProgress(BaseModel):
"""Progress information for a task"""
current_step: str
total_steps: int
completed_steps: int
percentage: float
message: Optional[str] = None
estimated_time_remaining: Optional[float] = None
class DatasetBuildTaskStatus(BaseModel):
"""Status model for dataset building task"""
task_id: str
status: TaskStatus
dataset_id: Optional[str] = None
aid_list: List[int]
embedding_model: str
force_regenerate: bool
progress: Optional[TaskProgress] = None
error_message: Optional[str] = None
created_at: datetime
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
result: Optional[Dict[str, Any]] = None
class DatasetBuildRequest(BaseModel):
@ -64,45 +106,41 @@ class EmbeddingModelInfo(BaseModel):
max_batch_size: Optional[int] = None
from typing import List, Optional, Dict, Any, Literal
from pydantic import BaseModel, Field
from datetime import datetime
from enum import Enum
# Sampling and Dataset Selection Models
class SamplingRequest(BaseModel):
"""Request model for dataset sampling"""
strategy: SamplingStrategy = Field(..., description="Sampling strategy to use")
limit: Optional[int] = Field(None, description="Maximum number of AIDs to sample (for random sampling)")
class TaskStatus(str, Enum):
"""Task status enumeration"""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class TaskProgress(BaseModel):
"""Progress information for a task"""
current_step: str
total_steps: int
completed_steps: int
percentage: float
message: Optional[str] = None
estimated_time_remaining: Optional[float] = None
class DatasetBuildTaskStatus(BaseModel):
"""Status model for dataset building task"""
task_id: str
status: TaskStatus
dataset_id: Optional[str] = None
class SamplingResponse(BaseModel):
"""Response model for dataset sampling"""
strategy: SamplingStrategy
total_available: int
sampled_count: int
aid_list: List[int]
embedding_model: str
force_regenerate: bool
progress: Optional[TaskProgress] = None
error_message: Optional[str] = None
created_at: datetime
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
result: Optional[Dict[str, Any]] = None
filters_applied: Optional[Dict[str, Any]] = None
sampling_info: Dict[str, Any]
class DatasetCreateRequest(BaseModel):
"""Request model for creating dataset with sampling"""
sampling: SamplingRequest = Field(..., description="Sampling configuration")
embedding_model: str = Field(..., description="Embedding model name")
force_regenerate: bool = Field(False, description="Whether to force regenerate embeddings")
description: Optional[str] = Field(None, description="Optional description for the dataset")
class DatasetCreateResponse(BaseModel):
"""Response model for dataset creation"""
dataset_id: str
sampling_response: SamplingResponse
task_id: str
total_records: int
status: str
message: str
description: Optional[str] = None
class TaskStatusResponse(BaseModel):

View File

@ -18,6 +18,7 @@
}
},
"dependencies": {
"@tanstack/react-query": "^5.90.12",
"arg": "^5.0.2",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",

View File

@ -0,0 +1 @@
src/components/ui

View File

@ -170,7 +170,7 @@ graph TD
```mermaid
graph TD
A[PostgreSQL 远程数据库<br/>RTT: 100ms] --> B[videoTypeLabelInInternal]
A --> C[embeddingsInInternal]
A --> C[embeddingsInInternal]
A --> D[bilibiliMetadata]
B --> E[批量获取用户最后一次标注<br/>避免循环查询]
C --> F[批量获取嵌入向量<br/>一次性查询所有维度]
@ -226,32 +226,32 @@ graph TD
### Phase 1: 核心功能 (优先)
1. **数据管线实现**
- 标注数据获取和一致性检查
- 嵌入向量生成和存储
- 数据集构建逻辑
- 标注数据获取和一致性检查
- 嵌入向量生成和存储
- 数据集构建逻辑
2. **FastAPI 服务完善**
- 构建新的模型架构(输入嵌入向量,直接二分类头)
- 迁移现有 ml/filter 训练逻辑
- 实现超参数动态暴露
- 集成 OpenAI 兼容嵌入 API
- 训练任务队列管理
- 构建新的模型架构(输入嵌入向量,直接二分类头)
- 迁移现有 ml/filter 训练逻辑
- 实现超参数动态暴露
- 集成 OpenAI 兼容嵌入 API
- 训练任务队列管理
### Phase 2: 用户界面
1. **数据集创建界面**
- 嵌入模型选择
- 数据预览和筛选
- 处理进度显示
- 嵌入模型选择
- 数据预览和筛选
- 处理进度显示
2. **训练参数配置界面**
- 超参数动态渲染
- 参数验证和约束
- 超参数动态渲染
- 参数验证和约束
3. **实验管理和追踪**
- 实验历史和比较
- 训练状态实时监控
- 结果可视化
- 实验历史和比较
- 训练状态实时监控
- 结果可视化
### Phase 3: 高级功能
@ -264,4 +264,4 @@ graph TD
1. **数据库性能**: 远程数据库 RTT 高,避免 N+1 查询,使用批量操作
2. **标注一致性**: 实现自动的标注不一致检测
3. **嵌入模型支持**: 为未来扩展多种嵌入模型预留接口
3. **嵌入模型支持**: 为未来扩展多种嵌入模型预留接口

View File

@ -1,22 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -10,6 +10,12 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@ -1,5 +1,72 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CardDescription, CardTitle } from "@/components/ui/card";
import { DatasetManager } from "@/components/DatasetManager";
import { TaskMonitor } from "@/components/TaskMonitor";
import { SamplingPanel } from "@/components/SamplingPanel";
import { Database, Activity, Settings } from "lucide-react";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 3,
refetchOnWindowFocus: false
}
}
});
function App() {
return <></>;
return (
<QueryClientProvider client={queryClient}>
<div className="min-h-screen flex justify-center">
<div className="container lg:max-w-3xl xl:max-w-4xl bg-background py-8 px-3">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">ML Dataset Management Panel</h1>
<p className="text-muted-foreground">
Create and manage machine learning datasets with multiple sampling strategies and task monitoring
</p>
</div>
<Tabs defaultValue="datasets" className="space-y-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="datasets" className="flex items-center gap-2">
<Database className="h-4 w-4" />
Datasets
</TabsTrigger>
<TabsTrigger value="sampling" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
Sampling
</TabsTrigger>
<TabsTrigger value="monitor" className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Tasks
</TabsTrigger>
</TabsList>
<TabsContent value="datasets" className="space-y-4">
<CardTitle>Dataset Management</CardTitle>
<CardDescription>View, create and manage your machine learning datasets</CardDescription>
<DatasetManager />
</TabsContent>
<TabsContent value="sampling" className="space-y-4">
<CardTitle>Sampling Strategy Configuration</CardTitle>
<CardDescription>
Configure different data sampling strategies to create balanced datasets
</CardDescription>
<SamplingPanel />
</TabsContent>
<TabsContent value="monitor" className="space-y-4">
<CardTitle>Task Monitor</CardTitle>
<CardDescription>Monitor real-time status and progress of dataset building tasks</CardDescription>
<TaskMonitor />
</TabsContent>
</Tabs>
</div>
</div>
</QueryClientProvider>
);
}
export default App;

View File

@ -0,0 +1,337 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Trash2, Plus, Database, FileText, Calendar, Activity } from "lucide-react";
import { apiClient } from "@/lib/api";
import { toast } from "sonner";
import { Spinner } from "@/components/ui/spinner"
export function DatasetManager() {
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [createFormData, setCreateFormData] = useState({
strategy: "all",
limit: "",
embeddingModel: "",
description: "",
forceRegenerate: false
});
const queryClient = useQueryClient();
// Fetch datasets
const { data: datasetsData, isLoading: datasetsLoading } = useQuery({
queryKey: ["datasets"],
queryFn: () => apiClient.getDatasets(),
refetchInterval: 30000 // Refresh every 30 seconds
});
// Fetch embedding models
const { data: modelsData, isLoading: modelsLoading } = useQuery({
queryKey: ["embedding-models"],
queryFn: () => apiClient.getEmbeddingModels()
});
// Create dataset mutation
const createDatasetMutation = useMutation({
mutationFn: (data: any) => apiClient.createDatasetWithSampling(data),
onSuccess: () => {
toast.success("Dataset creation task started");
setIsCreateDialogOpen(false);
setCreateFormData({
strategy: "all",
limit: "",
embeddingModel: "",
description: "",
forceRegenerate: false
});
queryClient.invalidateQueries({ queryKey: ["datasets"] });
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
onError: (error: Error) => {
toast.error(`Creation failed: ${error.message}`);
}
});
// Delete dataset mutation
const deleteDatasetMutation = useMutation({
mutationFn: (datasetId: string) => apiClient.deleteDataset(datasetId),
onSuccess: () => {
toast.success("Dataset deleted");
queryClient.invalidateQueries({ queryKey: ["datasets"] });
},
onError: (error: Error) => {
toast.error(`Delete failed: ${error.message}`);
}
});
const handleCreateDataset = () => {
if (!createFormData.embeddingModel) {
toast.error("Please select an embedding model");
return;
}
const requestData = {
sampling: {
strategy: createFormData.strategy,
...(createFormData.limit && { limit: parseInt(createFormData.limit) })
},
embedding_model: createFormData.embeddingModel,
force_regenerate: createFormData.forceRegenerate,
description: createFormData.description || undefined
};
createDatasetMutation.mutate(requestData);
};
const handleDeleteDataset = (datasetId: string) => {
if (window.confirm("Are you sure you want to delete this dataset?")) {
deleteDatasetMutation.mutate(datasetId);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString("en-US");
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
if (datasetsLoading) {
return (
<div className="flex items-center justify-center h-64">
<Spinner />
</div>
);
}
return (
<div className="space-y-6">
{/* Create Dataset Button */}
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium">Dataset List</h3>
<p className="text-sm text-muted-foreground">
{datasetsData?.datasets?.length || 0} datasets created
</p>
</div>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Dataset
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Create New Dataset</DialogTitle>
<DialogDescription>
Select sampling strategy and configuration parameters to create a new dataset
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="strategy">Sampling Strategy</Label>
<Select
value={createFormData.strategy}
onValueChange={(value) =>
setCreateFormData((prev) => ({ ...prev, strategy: value }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select sampling strategy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Videos</SelectItem>
<SelectItem value="random">Random Sampling</SelectItem>
</SelectContent>
</Select>
</div>
{createFormData.strategy === "random" && (
<div className="grid gap-2">
<Label htmlFor="limit">Sample Count</Label>
<Textarea
id="limit"
placeholder="Enter number of samples, e.g., 1000"
value={createFormData.limit}
onChange={(e) =>
setCreateFormData((prev) => ({
...prev,
limit: e.target.value
}))
}
/>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="model">Embedding Model</Label>
<Select
value={createFormData.embeddingModel}
onValueChange={(value) =>
setCreateFormData((prev) => ({
...prev,
embeddingModel: value
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Select embedding model" />
</SelectTrigger>
<SelectContent>
{modelsData?.models &&
Object.keys(modelsData.models).map((modelName) => (
<SelectItem key={modelName} value={modelName}>
{modelName} (
{modelsData.models[modelName].dimensions}D)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description (Optional)</Label>
<Textarea
id="description"
placeholder="Enter dataset description"
value={createFormData.description}
onChange={(e) =>
setCreateFormData((prev) => ({
...prev,
description: e.target.value
}))
}
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="forceRegenerate"
checked={createFormData.forceRegenerate}
onChange={(e) =>
setCreateFormData((prev) => ({
...prev,
forceRegenerate: e.target.checked
}))
}
/>
<Label htmlFor="forceRegenerate">Force Regenerate Embeddings</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleCreateDataset}
disabled={createDatasetMutation.isPending}
>
{createDatasetMutation.isPending ? "Creating..." : "Create Dataset"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Datasets List */}
<div className="grid gap-4">
{datasetsData?.datasets && datasetsData.datasets.length > 0 ? (
datasetsData.datasets.map((dataset: any) => (
<Card key={dataset.dataset_id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-2">
<Database className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{dataset.dataset_id.slice(0, 8)}...{dataset.dataset_id.slice(-8)}
</CardTitle>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteDataset(dataset.dataset_id)}
disabled={deleteDatasetMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{dataset.description && (
<CardDescription>{dataset.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 text-sm">
<div className="flex items-center space-x-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span>{dataset.stats.total_records} records</span>
</div>
<div className="flex items-center space-x-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<span>{dataset.stats.embedding_model}</span>
</div>
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>{formatDate(dataset.created_at)}</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-muted-foreground">
New: {dataset.stats.new_embeddings}
</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-muted-foreground">
New: {dataset.stats.new_embeddings}
</span>
</div>
</div>
</CardContent>
</Card>
))
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Database className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No Datasets</h3>
<p className="text-sm text-muted-foreground text-center mb-4">
Start by creating your first dataset
</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Dataset
</Button>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,234 @@
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Database, Play, TestTube, Settings, BarChart3 } from "lucide-react";
import { apiClient } from "@/lib/api";
import type { SamplingResponse, DatasetCreateResponse } from "@/types/api";
interface SamplingConfig {
strategy: string;
limit?: number;
}
export function SamplingPanel() {
const [samplingConfig, setSamplingConfig] = useState<SamplingConfig>({
strategy: "all",
limit: undefined,
});
const [embeddingModel, setEmbeddingModel] = useState<string>("");
const [description, setDescription] = useState<string>("");
// Test sampling mutation
const testSamplingMutation = useMutation({
mutationFn: (config: SamplingConfig) => apiClient.sampleDataset(config),
onSuccess: (data: SamplingResponse) => {
console.log("Sampling test successful:", data);
},
onError: (error: Error) => {
console.error("Sampling test failed:", error);
}
});
// Create dataset with sampling mutation
const createDatasetMutation = useMutation({
mutationFn: (config: {
sampling: SamplingConfig;
embedding_model: string;
description?: string;
}) => apiClient.createDatasetWithSampling(config),
onSuccess: (data: DatasetCreateResponse) => {
console.log("Dataset created successfully:", data);
},
onError: (error: Error) => {
console.error("Dataset creation failed:", error);
}
});
const handleStrategyChange = (strategy: string) => {
setSamplingConfig((prev) => ({ ...prev, strategy }));
};
const handleLimitChange = (limit: string) => {
setSamplingConfig((prev) => ({
...prev,
limit: limit ? parseInt(limit) : undefined
}));
};
const handleTestSampling = () => {
testSamplingMutation.mutate(samplingConfig);
};
const handleCreateDataset = () => {
if (!embeddingModel) {
alert("Please select an embedding model");
return;
}
createDatasetMutation.mutate({
sampling: samplingConfig,
embedding_model: embeddingModel,
description: description || undefined
});
};
const getStrategyDescription = (strategy: string) => {
switch (strategy) {
case "all":
return "Sample all labeled videos";
case "random":
return "Randomly sample specified number of labeled videos";
default:
return "Unknown strategy";
}
};
return (
<div className="space-y-6">
<Tabs defaultValue="configure" className="w-full">
<TabsList className="w-full mb-4">
<TabsTrigger value="configure">
<Settings className="h-4 w-4 mr-2" />
Configure Sampling
</TabsTrigger>
<TabsTrigger value="test">
<TestTube className="h-4 w-4 mr-2" />
Test Sampling
</TabsTrigger>
</TabsList>
<TabsContent value="configure" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Sampling Strategy Configuration</CardTitle>
<CardDescription>Select data sampling strategy and parameters</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="strategy">Sampling Strategy</Label>
<Select
value={samplingConfig.strategy}
onValueChange={handleStrategyChange}
>
<SelectTrigger>
<SelectValue placeholder="Select strategy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Labeled Videos</SelectItem>
<SelectItem value="random">Random Sampling</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{getStrategyDescription(samplingConfig.strategy)}
</p>
</div>
{samplingConfig.strategy === "random" && (
<div className="space-y-2">
<Label htmlFor="limit">Sample Count</Label>
<Input
id="limit"
type="number"
placeholder="e.g., 1000"
value={samplingConfig.limit || ""}
onChange={(e) => handleLimitChange(e.target.value)}
/>
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="test" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Test Sampling</CardTitle>
<CardDescription>
Test sampling strategy and view data statistics for sampling
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex space-x-4">
<Button
onClick={handleTestSampling}
disabled={testSamplingMutation.isPending}
className="flex-1"
>
<Play className="h-4 w-4 mr-2" />
{testSamplingMutation.isPending ? "Testing..." : "Start Test"}
</Button>
</div>
{testSamplingMutation.isSuccess && testSamplingMutation.data && (
<Alert>
<BarChart3 className="h-4 w-4" />
<AlertDescription>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span>Total available data:</span>
<Badge variant="outline">
{(
testSamplingMutation.data as SamplingResponse
).total_available.toLocaleString()}
</Badge>
</div>
<div className="flex items-center justify-between">
<span>Will sample:</span>
<Badge>
{(
testSamplingMutation.data as SamplingResponse
).sampled_count.toLocaleString()}
</Badge>
</div>
<div className="flex items-center justify-between">
<span>Sampling ratio:</span>
<Badge variant="secondary">
{(
((
testSamplingMutation.data as SamplingResponse
).sampled_count /
(
testSamplingMutation.data as SamplingResponse
).total_available) *
100
).toFixed(1)}
%
</Badge>
</div>
</div>
</AlertDescription>
</Alert>
)}
{testSamplingMutation.isError && (
<Alert variant="destructive">
<AlertDescription>
Test failed: {(testSamplingMutation.error as Error).message}
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,210 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { RefreshCw, Play, Pause, CheckCircle, XCircle, Clock } from "lucide-react";
import { apiClient } from "@/lib/api";
import type { TasksResponse } from "@/types/api";
import { Spinner } from "@/components/ui/spinner"
export function TaskMonitor() {
const [statusFilter, setStatusFilter] = useState<string>("all");
// Fetch tasks
const {
data: tasksData,
isLoading: tasksLoading,
refetch: refetchTasks
} = useQuery<TasksResponse>({
queryKey: ["tasks", statusFilter],
queryFn: () => {
const params = statusFilter === "all" ? {} : { status: statusFilter };
return apiClient.getTasks(params.status, 50);
},
refetchInterval: 5000 // Refresh every 5 seconds
});
const getStatusIcon = (status: string) => {
switch (status) {
case "running":
return <Play className="h-4 w-4 text-blue-500" />;
case "completed":
return <CheckCircle className="h-4 w-4 text-green-500" />;
case "failed":
return <XCircle className="h-4 w-4 text-red-500" />;
case "pending":
return <Clock className="h-4 w-4 text-yellow-500" />;
default:
return <Pause className="h-4 w-4 text-gray-500" />;
}
};
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case "running":
return "default";
case "completed":
return "secondary";
case "failed":
return "destructive";
case "pending":
return "outline";
default:
return "outline";
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString("en-US");
};
const formatDuration = (start: string, end?: string) => {
const startTime = new Date(start).getTime();
const endTime = end ? new Date(end).getTime() : Date.now();
const duration = Math.floor((endTime - startTime) / 1000);
if (duration < 60) return `${duration}s`;
if (duration < 3600) return `${Math.floor(duration / 60)}m ${duration % 60}s`;
return `${Math.floor(duration / 3600)}h ${Math.floor((duration % 3600) / 60)}m`;
};
if (tasksLoading) {
return (
<div className="flex items-center justify-center h-64">
<Spinner/>
</div>
);
}
return (
<div className="space-y-6">
{/* Controls */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Task Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="sm" onClick={() => refetchTasks()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{/* Tasks List */}
<div className="space-y-3">
{tasksData?.tasks && tasksData.tasks.length > 0 ? (
tasksData.tasks.map((task: any) => (
<Card key={task.task_id}>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-2">
{getStatusIcon(task.status)}
<span className="font-mono text-sm">
{task.task_id.slice(0, 8)}...
</span>
<Badge variant={getStatusBadgeVariant(task.status)}>
{task.status}
</Badge>
</div>
<div className="text-sm text-muted-foreground">
{formatDate(task.created_at)}
</div>
</div>
{task.progress && (
<div className="space-y-2 mb-3">
<div className="flex items-center justify-between text-sm">
<span>{task.progress.message}</span>
<span>{task.progress.percentage.toFixed(1)}%</span>
</div>
<Progress
value={task.progress.percentage}
className="h-2"
/>
<div className="text-xs text-muted-foreground">
Step {task.progress.completed_steps}/
{task.progress.total_steps}:{" "}
{task.progress.current_step}
</div>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
{task.started_at && (
<div>
<span className="text-muted-foreground">Start Time:</span>
<br />
{formatDate(task.started_at)}
</div>
)}
{task.completed_at && (
<div>
<span className="text-muted-foreground">Complete Time:</span>
<br />
{formatDate(task.completed_at)}
</div>
)}
<div>
<span className="text-muted-foreground">Duration:</span>
<br />
{formatDuration(
task.started_at || task.created_at,
task.completed_at
)}
</div>
{task.result && (
<div>
<span className="text-muted-foreground">Result:</span>
<br />
{task.result.dataset_id
? `Dataset: ${task.result.dataset_id.slice(0, 8)}...`
: "None"}
</div>
)}
</div>
{task.error && (
<div className="mt-3 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
<strong>Error:</strong> {task.error}
</div>
)}
</CardContent>
</Card>
))
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Clock className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No Tasks</h3>
<p className="text-sm text-muted-foreground text-center">
{statusFilter === "all"
? "No tasks found"
: `No ${statusFilter} tasks found`}
</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90"
}
},
defaultVariants: {
variant: "default"
}
}
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@ -0,0 +1,40 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground"
}
},
defaultVariants: {
variant: "default"
}
}
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,58 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline"
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@ -0,0 +1,78 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

View File

@ -0,0 +1,127 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger
};

View File

@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
);
}
export { Input };

View File

@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
);
}
export { Label };

View File

@ -0,0 +1,29 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@ -0,0 +1,172 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue
};

View File

@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View File

@ -0,0 +1,52 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
);
}
export { Textarea };

View File

@ -4,117 +4,117 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,144 @@
// API client for ML training service
import type {
HealthResponse,
EmbeddingModelsResponse,
DatasetsResponse,
DatasetDetail,
SamplingStats,
SamplingResponse,
DatasetCreateResponse,
Task,
TasksResponse,
DatasetStatistics
} from "@/types/api";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000/v1";
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string = API_BASE_URL) {
this.baseUrl = baseUrl;
}
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
...options?.headers
},
...options
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
}
// Health check
async healthCheck(): Promise<HealthResponse> {
return this.request("/health");
}
// Embedding models
async getEmbeddingModels(): Promise<EmbeddingModelsResponse> {
return this.request("/models/embedding");
}
// Dataset operations
async getDatasets(): Promise<DatasetsResponse> {
return this.request("/datasets");
}
async getDataset(datasetId: string): Promise<DatasetDetail> {
return this.request(`/dataset/${datasetId}`);
}
async deleteDataset(datasetId: string): Promise<any> {
return this.request(`/dataset/${datasetId}`, {
method: "DELETE"
});
}
async buildDataset(data: {
aid_list: number[];
embedding_model: string;
force_regenerate?: boolean;
description?: string;
}): Promise<any> {
return this.request("/dataset/build", {
method: "POST",
body: JSON.stringify(data)
});
}
async sampleDataset(data: {
strategy: string;
limit?: number;
label_value?: boolean;
metadata_filter?: Record<string, any>;
}): Promise<SamplingResponse> {
return this.request("/dataset/sample", {
method: "POST",
body: JSON.stringify(data)
});
}
async createDatasetWithSampling(data: {
sampling: {
strategy: string;
limit?: number;
label_value?: boolean;
metadata_filter?: Record<string, any>;
};
embedding_model: string;
force_regenerate?: boolean;
description?: string;
}): Promise<DatasetCreateResponse> {
return this.request("/dataset/create-with-sampling", {
method: "POST",
body: JSON.stringify(data)
});
}
// Task operations
async getTask(taskId: string): Promise<Task> {
return this.request(`/tasks/${taskId}`);
}
async getTasks(status?: string, limit: number = 50): Promise<TasksResponse> {
const params = new URLSearchParams();
if (status) params.append("status", status);
params.append("limit", limit.toString());
return this.request(`/tasks?${params.toString()}`);
}
async cleanupTasks(maxAgeHours: number = 24): Promise<any> {
return this.request("/tasks/cleanup", {
method: "POST",
body: JSON.stringify({ max_age_hours: maxAgeHours })
});
}
// Dataset statistics
async getDatasetStatistics(): Promise<DatasetStatistics> {
return this.request("/datasets/stats");
}
async cleanupDatasets(maxAgeDays: number = 30): Promise<any> {
return this.request("/datasets/cleanup", {
method: "POST",
body: JSON.stringify({ max_age_days: maxAgeDays })
});
}
}
export const apiClient = new ApiClient();
export default apiClient;

View File

@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,125 @@
// API types for ML training service
export interface EmbeddingModel {
name: string;
dimensions: number;
type: string;
api_endpoint?: string;
max_tokens?: number;
max_batch_size?: number;
}
export interface EmbeddingModelsResponse {
models: Record<string, EmbeddingModel>;
}
export interface DatasetStats {
total_records: number;
new_embeddings: number;
reused_embeddings: number;
inconsistent_labels: number;
embedding_model: string;
}
export interface Dataset {
dataset_id: string;
description?: string;
stats: DatasetStats;
created_at: string;
}
export interface DatasetsResponse {
datasets: Dataset[];
}
export interface DatasetDetail {
dataset_id: string;
dataset: any[];
description?: string;
stats: DatasetStats;
created_at: string;
}
export interface SamplingRequest {
strategy: string;
limit?: number;
}
export interface SamplingResponse {
strategy: string;
total_available: number;
sampled_count: number;
aid_list: number[];
filters_applied?: Record<string, any>;
sampling_info: Record<string, any>;
}
export interface DatasetCreateRequest {
sampling: SamplingRequest;
embedding_model: string;
force_regenerate?: boolean;
description?: string;
}
export interface DatasetCreateResponse {
dataset_id: string;
sampling_response: SamplingResponse;
task_id: string;
total_records: number;
status: string;
message: string;
description?: string;
}
export interface TaskProgress {
current_step: string;
total_steps: number;
completed_steps: number;
percentage: number;
message?: string;
estimated_time_remaining?: number;
}
export interface Task {
task_id: string;
status: string;
progress?: TaskProgress;
result?: Record<string, any>;
error?: string;
created_at: string;
started_at?: string;
completed_at?: string;
}
export interface TasksResponse {
tasks: Task[];
total_count: number;
pending_count: number;
running_count: number;
completed_count: number;
failed_count: number;
}
export interface SamplingStats {
total_labeled_videos: number;
positive_labels: number;
negative_labels: number;
}
export interface DatasetStatistics {
total_datasets: number;
valid_datasets: number;
error_datasets: number;
total_records: number;
total_new_embeddings: number;
total_reused_embeddings: number;
storage_directory: string;
}
export interface HealthResponse {
status: string;
service: string;
embedding_service: any;
database: string;
available_models: string[];
}