|
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; |
|
|