| | import React, { useState, useEffect } from "react"; |
| | import { |
| | Box, |
| | Typography, |
| | Paper, |
| | Button, |
| | Alert, |
| | List, |
| | ListItem, |
| | CircularProgress, |
| | Chip, |
| | Divider, |
| | IconButton, |
| | Stack, |
| | Link, |
| | } from "@mui/material"; |
| | import AccessTimeIcon from "@mui/icons-material/AccessTime"; |
| | import PersonIcon from "@mui/icons-material/Person"; |
| | import OpenInNewIcon from "@mui/icons-material/OpenInNew"; |
| | import HowToVoteIcon from "@mui/icons-material/HowToVote"; |
| | import { useAuth } from "../../hooks/useAuth"; |
| | import PageHeader from "../../components/shared/PageHeader"; |
| | import AuthContainer from "../../components/shared/AuthContainer"; |
| | import { alpha } from "@mui/material/styles"; |
| | import CheckIcon from "@mui/icons-material/Check"; |
| |
|
| | const NoModelsToVote = () => ( |
| | <Box |
| | sx={{ |
| | display: "flex", |
| | flexDirection: "column", |
| | alignItems: "center", |
| | justifyContent: "center", |
| | py: 8, |
| | textAlign: "center", |
| | }} |
| | > |
| | <HowToVoteIcon |
| | sx={{ |
| | fontSize: 100, |
| | color: "grey.300", |
| | mb: 3, |
| | }} |
| | /> |
| | <Typography |
| | variant="h4" |
| | component="h2" |
| | sx={{ |
| | fontWeight: "bold", |
| | color: "grey.700", |
| | mb: 2, |
| | }} |
| | > |
| | No Models to Vote |
| | </Typography> |
| | <Typography |
| | variant="body1" |
| | sx={{ |
| | color: "grey.600", |
| | maxWidth: 450, |
| | mx: "auto", |
| | }} |
| | > |
| | There are currently no models waiting for votes. |
| | <br /> |
| | Check back later! |
| | </Typography> |
| | </Box> |
| | ); |
| |
|
| | function VoteModelPage() { |
| | const { isAuthenticated, user, loading } = useAuth(); |
| | const [pendingModels, setPendingModels] = useState([]); |
| | const [loadingModels, setLoadingModels] = useState(true); |
| | const [error, setError] = useState(null); |
| | const [userVotes, setUserVotes] = useState(new Set()); |
| |
|
| | const formatWaitTime = (submissionTime) => { |
| | if (!submissionTime) return "N/A"; |
| |
|
| | const now = new Date(); |
| | const submitted = new Date(submissionTime); |
| | const diffInHours = Math.floor((now - submitted) / (1000 * 60 * 60)); |
| |
|
| | |
| | if (diffInHours < 24) { |
| | return `${diffInHours}h`; |
| | } |
| |
|
| | |
| | const diffInDays = Math.floor(diffInHours / 24); |
| | if (diffInDays < 7) { |
| | return `${diffInDays}d`; |
| | } |
| |
|
| | |
| | const diffInWeeks = Math.floor(diffInDays / 7); |
| | return `${diffInWeeks}w`; |
| | }; |
| |
|
| | |
| | useEffect(() => { |
| | const fetchUserVotes = async () => { |
| | if (!isAuthenticated || !user) return; |
| |
|
| | try { |
| | |
| | const localVotes = JSON.parse( |
| | localStorage.getItem(`votes_${user.username}`) || "[]" |
| | ); |
| | const localVotesSet = new Set(localVotes); |
| |
|
| | |
| | const response = await fetch(`/api/votes/user/${user.username}`); |
| | if (!response.ok) { |
| | throw new Error("Failed to fetch user votes"); |
| | } |
| | const data = await response.json(); |
| |
|
| | |
| | const votedModels = new Set([ |
| | ...data.map((vote) => vote.model), |
| | ...localVotesSet, |
| | ]); |
| | setUserVotes(votedModels); |
| | } catch (err) { |
| | console.error("Error fetching user votes:", err); |
| | } |
| | }; |
| |
|
| | fetchUserVotes(); |
| | }, [isAuthenticated, user]); |
| |
|
| | useEffect(() => { |
| | const fetchModels = async () => { |
| | try { |
| | const response = await fetch("/api/models/pending"); |
| | if (!response.ok) { |
| | throw new Error("Failed to fetch pending models"); |
| | } |
| | const data = await response.json(); |
| |
|
| | |
| | const modelsWithVotes = await Promise.all( |
| | data.map(async (model) => { |
| | const [provider, modelName] = model.name.split("/"); |
| | const votesResponse = await fetch( |
| | `/api/votes/model/${provider}/${modelName}` |
| | ); |
| | const votesData = await votesResponse.json(); |
| |
|
| | |
| | const totalScore = Object.values( |
| | votesData.votes_by_revision || {} |
| | ).reduce((a, b) => a + b, 0); |
| |
|
| | |
| | const waitTimeDisplay = formatWaitTime(model.submission_time); |
| |
|
| | return { |
| | ...model, |
| | votes: totalScore, |
| | votes_by_revision: votesData.votes_by_revision, |
| | wait_time: waitTimeDisplay, |
| | hasVoted: userVotes.has(model.name), |
| | }; |
| | }) |
| | ); |
| |
|
| | |
| | const sortedModels = modelsWithVotes.sort((a, b) => b.votes - a.votes); |
| |
|
| | setPendingModels(sortedModels); |
| | } catch (err) { |
| | setError(err.message); |
| | } finally { |
| | setLoadingModels(false); |
| | } |
| | }; |
| |
|
| | fetchModels(); |
| | }, [userVotes]); |
| |
|
| | const handleVote = async (modelName) => { |
| | if (!isAuthenticated) return; |
| |
|
| | try { |
| | |
| | setUserVotes((prev) => { |
| | const newSet = new Set([...prev, modelName]); |
| | |
| | if (user) { |
| | const localVotes = JSON.parse( |
| | localStorage.getItem(`votes_${user.username}`) || "[]" |
| | ); |
| | if (!localVotes.includes(modelName)) { |
| | localVotes.push(modelName); |
| | localStorage.setItem( |
| | `votes_${user.username}`, |
| | JSON.stringify(localVotes) |
| | ); |
| | } |
| | } |
| | return newSet; |
| | }); |
| |
|
| | |
| | const [provider, model] = modelName.split("/"); |
| |
|
| | const response = await fetch( |
| | `/api/votes/${modelName}?vote_type=up&user_id=${user.username}`, |
| | { |
| | method: "POST", |
| | headers: { |
| | "Content-Type": "application/json", |
| | }, |
| | } |
| | ); |
| |
|
| | if (!response.ok) { |
| | |
| | setUserVotes((prev) => { |
| | const newSet = new Set(prev); |
| | newSet.delete(modelName); |
| | if (user) { |
| | const localVotes = JSON.parse( |
| | localStorage.getItem(`votes_${user.username}`) || "[]" |
| | ); |
| | const updatedVotes = localVotes.filter( |
| | (vote) => vote !== modelName |
| | ); |
| | localStorage.setItem( |
| | `votes_${user.username}`, |
| | JSON.stringify(updatedVotes) |
| | ); |
| | } |
| | return newSet; |
| | }); |
| | throw new Error("Failed to submit vote"); |
| | } |
| |
|
| | |
| | const votesResponse = await fetch( |
| | `/api/votes/model/${provider}/${model}` |
| | ); |
| | const votesData = await votesResponse.json(); |
| |
|
| | |
| | const totalScore = Object.values( |
| | votesData.votes_by_revision || {} |
| | ).reduce((a, b) => a + b, 0); |
| |
|
| | |
| | setPendingModels((models) => { |
| | const updatedModels = models.map((model) => |
| | model.name === modelName |
| | ? { |
| | ...model, |
| | votes: totalScore, |
| | votes_by_revision: votesData.votes_by_revision, |
| | } |
| | : model |
| | ); |
| | return updatedModels.sort((a, b) => b.votes - a.votes); |
| | }); |
| | } catch (err) { |
| | setError(err.message); |
| | } |
| | }; |
| |
|
| | if (loading) { |
| | return ( |
| | <Box |
| | sx={{ |
| | display: "flex", |
| | justifyContent: "center", |
| | alignItems: "center", |
| | height: "100vh", |
| | }} |
| | > |
| | <CircularProgress /> |
| | </Box> |
| | ); |
| | } |
| |
|
| | return ( |
| | <Box sx={{ width: "100%", maxWidth: 1200, margin: "0 auto", padding: 4 }}> |
| | <PageHeader |
| | title="Vote for the Next Models" |
| | subtitle={ |
| | <> |
| | Help us <span style={{ fontWeight: 600 }}>prioritize</span> which |
| | models to evaluate next |
| | </> |
| | } |
| | /> |
| |
|
| | {error && ( |
| | <Alert severity="error" sx={{ mb: 2 }}> |
| | {error} |
| | </Alert> |
| | )} |
| |
|
| | {} |
| | { |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | } |
| | <AuthContainer actionText="vote for models" /> |
| |
|
| | {} |
| | <Paper |
| | elevation={0} |
| | sx={{ |
| | border: "1px solid", |
| | borderColor: "grey.300", |
| | borderRadius: 1, |
| | overflow: "hidden", |
| | minHeight: 400, |
| | }} |
| | > |
| | {} |
| | <Box |
| | sx={{ |
| | px: 3, |
| | py: 2, |
| | borderBottom: "1px solid", |
| | borderColor: (theme) => |
| | theme.palette.mode === "dark" |
| | ? alpha(theme.palette.divider, 0.1) |
| | : "grey.200", |
| | bgcolor: (theme) => |
| | theme.palette.mode === "dark" |
| | ? alpha(theme.palette.background.paper, 0.5) |
| | : "grey.50", |
| | }} |
| | > |
| | <Typography |
| | variant="h6" |
| | sx={{ fontWeight: 600, color: "text.primary" }} |
| | > |
| | Models Pending Evaluation |
| | </Typography> |
| | </Box> |
| |
|
| | {} |
| | <Box |
| | sx={{ |
| | px: 3, |
| | py: 1.5, |
| | borderBottom: "1px solid", |
| | borderColor: "divider", |
| | bgcolor: "background.paper", |
| | display: "grid", |
| | gridTemplateColumns: "1fr 200px 160px", |
| | gap: 3, |
| | alignItems: "center", |
| | }} |
| | > |
| | <Box> |
| | <Typography variant="subtitle2" color="text.secondary"> |
| | Model |
| | </Typography> |
| | </Box> |
| | <Box sx={{ textAlign: "right" }}> |
| | <Typography variant="subtitle2" color="text.secondary"> |
| | Votes |
| | </Typography> |
| | </Box> |
| | <Box sx={{ textAlign: "right" }}> |
| | <Typography variant="subtitle2" color="text.secondary"> |
| | Priority |
| | </Typography> |
| | </Box> |
| | </Box> |
| |
|
| | {} |
| | {loadingModels ? ( |
| | <Box |
| | sx={{ |
| | display: "flex", |
| | justifyContent: "center", |
| | alignItems: "center", |
| | height: "200px", |
| | width: "100%", |
| | bgcolor: "background.paper", |
| | }} |
| | > |
| | <CircularProgress /> |
| | </Box> |
| | ) : pendingModels.length === 0 && !loadingModels ? ( |
| | <NoModelsToVote /> |
| | ) : ( |
| | <List sx={{ p: 0, bgcolor: "background.paper" }}> |
| | {pendingModels.map((model, index) => { |
| | const isTopThree = index < 3; |
| | return ( |
| | <React.Fragment key={model.name}> |
| | {index > 0 && <Divider />} |
| | <ListItem |
| | sx={{ |
| | py: 2.5, |
| | px: 3, |
| | display: "grid", |
| | gridTemplateColumns: "1fr 200px 160px", |
| | gap: 3, |
| | alignItems: "center", |
| | position: "relative", |
| | "&:hover": { |
| | bgcolor: "action.hover", |
| | }, |
| | }} |
| | > |
| | {/* Left side - Model info */} |
| | <Box> |
| | <Stack spacing={1}> |
| | {/* Model name and link */} |
| | <Stack direction="row" spacing={1} alignItems="center"> |
| | <Link |
| | href={`https://huggingface.co/${model.name}`} |
| | target="_blank" |
| | rel="noopener noreferrer" |
| | sx={{ |
| | textDecoration: "none", |
| | color: "primary.main", |
| | fontWeight: 500, |
| | "&:hover": { |
| | textDecoration: "underline", |
| | }, |
| | }} |
| | > |
| | {model.name} |
| | </Link> |
| | <IconButton |
| | size="small" |
| | href={`https://huggingface.co/${model.name}`} |
| | target="_blank" |
| | rel="noopener noreferrer" |
| | sx={{ |
| | ml: 0.5, |
| | p: 0.5, |
| | color: "action.active", |
| | "&:hover": { |
| | color: "primary.main", |
| | }, |
| | }} |
| | > |
| | <OpenInNewIcon sx={{ fontSize: "1rem" }} /> |
| | </IconButton> |
| | </Stack> |
| | {/* Metadata row */} |
| | <Stack direction="row" spacing={2} alignItems="center"> |
| | <Stack |
| | direction="row" |
| | spacing={0.5} |
| | alignItems="center" |
| | > |
| | <AccessTimeIcon |
| | sx={{ |
| | fontSize: "0.875rem", |
| | color: "text.secondary", |
| | }} |
| | /> |
| | <Typography variant="body2" color="text.secondary"> |
| | {model.wait_time} |
| | </Typography> |
| | </Stack> |
| | <Stack |
| | direction="row" |
| | spacing={0.5} |
| | alignItems="center" |
| | > |
| | <PersonIcon |
| | sx={{ |
| | fontSize: "0.875rem", |
| | color: "text.secondary", |
| | }} |
| | /> |
| | <Typography variant="body2" color="text.secondary"> |
| | {model.submitter} |
| | </Typography> |
| | </Stack> |
| | </Stack> |
| | </Stack> |
| | </Box> |
| | |
| | {/* Vote Column */} |
| | <Box sx={{ textAlign: "right" }}> |
| | <Stack |
| | direction="row" |
| | spacing={2.5} |
| | justifyContent="flex-end" |
| | alignItems="center" |
| | > |
| | <Stack |
| | alignItems="center" |
| | sx={{ |
| | minWidth: "90px", |
| | }} |
| | > |
| | <Typography |
| | variant="h4" |
| | component="div" |
| | sx={{ |
| | fontWeight: 700, |
| | lineHeight: 1, |
| | fontSize: "2rem", |
| | display: "flex", |
| | alignItems: "center", |
| | justifyContent: "center", |
| | }} |
| | > |
| | <Typography |
| | component="span" |
| | sx={{ |
| | fontSize: "1.5rem", |
| | fontWeight: 600, |
| | color: "primary.main", |
| | lineHeight: 1, |
| | mr: 0.5, |
| | mt: "-2px", |
| | }} |
| | > |
| | + |
| | </Typography> |
| | <Typography |
| | component="span" |
| | sx={{ |
| | color: |
| | model.votes === 0 |
| | ? "text.primary" |
| | : "primary.main", |
| | fontWeight: 700, |
| | lineHeight: 1, |
| | }} |
| | > |
| | {model.votes > 999 ? "999" : model.votes} |
| | </Typography> |
| | </Typography> |
| | <Typography |
| | variant="caption" |
| | sx={{ |
| | color: "text.secondary", |
| | fontWeight: 500, |
| | mt: 0.5, |
| | textTransform: "uppercase", |
| | letterSpacing: "0.05em", |
| | fontSize: "0.75rem", |
| | }} |
| | > |
| | votes |
| | </Typography> |
| | </Stack> |
| | <Button |
| | variant={model.hasVoted ? "contained" : "outlined"} |
| | size="large" |
| | onClick={() => handleVote(model.name)} |
| | disabled={!isAuthenticated || model.hasVoted} |
| | color="primary" |
| | sx={{ |
| | minWidth: "100px", |
| | height: "40px", |
| | textTransform: "none", |
| | fontWeight: 600, |
| | fontSize: "0.95rem", |
| | ...(model.hasVoted |
| | ? { |
| | bgcolor: "primary.main", |
| | "&:hover": { |
| | bgcolor: "primary.dark", |
| | }, |
| | "&.Mui-disabled": { |
| | bgcolor: "primary.main", |
| | color: "white", |
| | opacity: 0.7, |
| | }, |
| | } |
| | : { |
| | borderWidth: 2, |
| | "&:hover": { |
| | borderWidth: 2, |
| | }, |
| | }), |
| | }} |
| | > |
| | {model.hasVoted ? ( |
| | <Stack |
| | direction="row" |
| | spacing={0.5} |
| | alignItems="center" |
| | > |
| | <CheckIcon sx={{ fontSize: "1.2rem" }} /> |
| | <span>Voted</span> |
| | </Stack> |
| | ) : ( |
| | "Vote" |
| | )} |
| | </Button> |
| | </Stack> |
| | </Box> |
| | |
| | {/* Priority Column */} |
| | <Box sx={{ textAlign: "right" }}> |
| | <Chip |
| | label={ |
| | <Stack |
| | direction="row" |
| | spacing={0.5} |
| | alignItems="center" |
| | > |
| | {isTopThree && ( |
| | <Typography |
| | variant="body2" |
| | sx={{ |
| | fontWeight: 600, |
| | color: isTopThree |
| | ? "primary.main" |
| | : "text.primary", |
| | letterSpacing: "0.02em", |
| | }} |
| | > |
| | HIGH |
| | </Typography> |
| | )} |
| | <Typography |
| | variant="body2" |
| | sx={{ |
| | fontWeight: 600, |
| | color: isTopThree |
| | ? "primary.main" |
| | : "text.secondary", |
| | letterSpacing: "0.02em", |
| | }} |
| | > |
| | #{index + 1} |
| | </Typography> |
| | </Stack> |
| | } |
| | size="medium" |
| | variant={isTopThree ? "filled" : "outlined"} |
| | sx={{ |
| | height: 36, |
| | minWidth: "100px", |
| | bgcolor: isTopThree |
| | ? (theme) => alpha(theme.palette.primary.main, 0.1) |
| | : "transparent", |
| | borderColor: isTopThree ? "primary.main" : "grey.300", |
| | borderWidth: 2, |
| | "& .MuiChip-label": { |
| | px: 2, |
| | fontSize: "0.95rem", |
| | }, |
| | }} |
| | /> |
| | </Box> |
| | </ListItem> |
| | </React.Fragment> |
| | ); |
| | })} |
| | </List> |
| | )} |
| | </Paper> |
| | </Box> |
| | ); |
| | } |
| |
|
| | export default VoteModelPage; |
| |
|