Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/src/api/bounties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
TreasuryDepositInfo,
EscrowVerifyPayload,
EscrowVerifyResult,
BountyReview,
} from '../types/bounty';

export interface BountiesListParams {
Expand Down Expand Up @@ -103,3 +104,7 @@ export async function verifyReviewFee(payload: {
}): Promise<{ verified: boolean; bounty_id: string; fndry_amount_verified?: number; error?: string }> {
return apiClient('/api/review-fee/verify', { method: 'POST', body: payload });
}

export async function getBountyReviews(bountyId: string): Promise<BountyReview[]> {
return apiClient<BountyReview[]>(`/api/bounties/${bountyId}/reviews`);
}
45 changes: 43 additions & 2 deletions frontend/src/components/bounty/BountyDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ArrowLeft, Clock, GitPullRequest, ExternalLink, Loader2, Check, Copy } from 'lucide-react';
import type { Bounty } from '../../types/bounty';
import type { Bounty, BountyReview } from '../../types/bounty';
import { timeLeft, timeAgo, formatCurrency, LANG_COLORS } from '../../lib/utils';
import { useAuth } from '../../hooks/useAuth';
import { SubmissionForm } from './SubmissionForm';
import { LLMReviewCard } from './LLMReviewCard';
import { getBountyReviews } from '../../api/bounties';
import { fadeIn } from '../../lib/animations';

interface BountyDetailProps {
Expand All @@ -16,6 +18,27 @@ export function BountyDetail({ bounty }: BountyDetailProps) {
const { isAuthenticated } = useAuth();
const [submitting, setSubmitting] = useState(false);
const [copied, setCopied] = useState(false);
const [reviews, setReviews] = useState<BountyReview[]>([]);
const [reviewsLoading, setReviewsLoading] = useState(true);
const [reviewsError, setReviewsError] = useState(false);

useEffect(() => {
let cancelled = false;
getBountyReviews(bounty.id)
.then((data) => {
if (!cancelled) {
setReviews(data);
setReviewsLoading(false);
}
})
.catch(() => {
if (!cancelled) {
setReviewsError(true);
setReviewsLoading(false);
}
});
return () => { cancelled = true; };
}, [bounty.id]);

const copyLink = () => {
navigator.clipboard.writeText(window.location.href).then(() => {
Expand Down Expand Up @@ -111,6 +134,24 @@ export function BountyDetail({ bounty }: BountyDetailProps) {
)}
</div>
) : null}

{/* LLM Review Results */}
{reviewsLoading && (
<div className="rounded-xl border border-border bg-forge-900 p-6">
<div className="flex items-center gap-3">
<Loader2 className="w-4 h-4 animate-spin text-text-muted" />
<span className="text-sm text-text-muted">Loading AI review results...</span>
</div>
</div>
)}
{reviewsError && (
<div className="rounded-xl border border-border bg-forge-900 p-6">
<p className="text-sm text-text-muted">AI review results unavailable.</p>
</div>
)}
{!reviewsLoading && !reviewsError && reviews.length > 0 && (
<LLMReviewCard reviews={reviews} />
)}
</div>

{/* Sidebar */}
Expand Down
193 changes: 193 additions & 0 deletions frontend/src/components/bounty/LLMReviewCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Brain, ChevronDown, ChevronUp, ThumbsUp, Lightbulb, ExternalLink } from 'lucide-react';
import type { LLMReviewScore, BountyReview } from '../../types/bounty';
import { staggerContainer, staggerItem, fadeIn } from '../../lib/animations';

const LLM_LOGOS: Record<string, { label: string; color: string; bg: string }> = {
Claude: { label: 'Claude', color: 'text-orange-400', bg: 'bg-orange-400/10' },
Codex: { label: 'Codex', color: 'text-green-400', bg: 'bg-green-400/10' },
Gemini: { label: 'Gemini', color: 'text-blue-400', bg: 'bg-blue-400/10' },
};

const QUALITY_COLORS: Record<string, string> = {
excellent: 'text-emerald',
good: 'text-blue-400',
average: 'text-yellow-400',
poor: 'text-red-400',
};

function ScoreBar({ score, maxScore }: { score: number; maxScore: number }) {
const pct = (score / maxScore) * 100;
const barColor =
pct >= 80 ? 'bg-emerald' : pct >= 60 ? 'bg-blue-400' : pct >= 40 ? 'bg-yellow-400' : 'bg-red-400';
return (
<div className="w-full h-2 rounded-full bg-forge-800 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className={`h-full rounded-full ${barColor}`}
/>
</div>
);
}

function LLMScoreCard({ score }: { score: LLMReviewScore }) {
const [expanded, setExpanded] = React.useState(false);
const meta = LLM_LOGOS[score.llm_name] ?? { label: score.llm_name, color: 'text-text-primary', bg: 'bg-forge-800' };

return (
<motion.div
variants={staggerItem}
className="rounded-xl border border-border bg-forge-900 p-5"
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-lg ${meta.bg} flex items-center justify-center`}>
<Brain className={`w-4 h-4 ${meta.color}`} />
</div>
<div>
<span className={`font-semibold text-sm ${meta.color}`}>{meta.label}</span>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-text-muted">Confidence</span>
<span className="text-xs font-mono text-text-primary">{Math.round(score.confidence * 100)}%</span>
</div>
</div>
</div>
<div className="text-right">
<span className={`font-mono text-2xl font-bold ${QUALITY_COLORS[score.quality]}`}>
{score.score.toFixed(1)}
</span>
<span className="text-text-muted text-xs font-mono">/{score.max_score}</span>
</div>
</div>

{/* Score bar */}
<ScoreBar score={score.score} maxScore={score.max_score} />

{/* Quality badge */}
<div className="flex items-center gap-2 mt-3">
<span className={`text-xs font-medium px-2 py-0.5 rounded-full capitalize ${
score.quality === 'excellent' ? 'bg-emerald/10 text-emerald' :
score.quality === 'good' ? 'bg-blue-400/10 text-blue-400' :
score.quality === 'average' ? 'bg-yellow-400/10 text-yellow-400' :
'bg-red-400/10 text-red-400'
}`}>
{score.quality}
</span>
<span className="text-xs text-text-muted">{score.summary}</span>
</div>

{/* Expand/collapse */}
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 mt-3 text-xs text-text-muted hover:text-text-secondary transition-colors"
>
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
{expanded ? 'Hide details' : 'Show details'}
</button>

{/* Expanded details */}
{expanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="mt-3 space-y-3 border-t border-border/50 pt-3"
>
{/* Strengths */}
{score.strengths.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs text-emerald mb-2">
<ThumbsUp className="w-3 h-3" /> Strengths
</div>
<ul className="space-y-1">
{score.strengths.map((s, i) => (
<li key={i} className="text-xs text-text-secondary pl-4 relative">
<span className="absolute left-0 top-1 text-emerald">•</span>
{s}
</li>
))}
</ul>
</div>
)}

{/* Improvements */}
{score.improvements.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs text-yellow-400 mb-2">
<Lightbulb className="w-3 h-3" /> Suggested Improvements
</div>
<ul className="space-y-1">
{score.improvements.map((s, i) => (
<li key={i} className="text-xs text-text-secondary pl-4 relative">
<span className="absolute left-0 top-1 text-yellow-400">•</span>
{s}
</li>
))}
</ul>
</div>
)}

{/* Reasoning */}
<div>
<div className="flex items-center gap-1.5 text-xs text-text-muted mb-2">
<ExternalLink className="w-3 h-3" /> Reasoning
</div>
<p className="text-xs text-text-secondary leading-relaxed">{score.reasoning}</p>
</div>
</motion.div>
)}
</motion.div>
);
}

interface LLMReviewCardProps {
reviews: BountyReview[];
}

export function LLMReviewCard({ reviews }: LLMReviewCardProps) {
if (!reviews || reviews.length === 0) return null;

return (
<motion.div variants={fadeIn} initial="initial" animate="animate" className="rounded-xl border border-border bg-forge-900 p-6">
<h2 className="font-sans text-lg font-semibold text-text-primary mb-6">
AI Review Results
</h2>

<motion.div variants={staggerContainer} initial="initial" animate="animate" className="space-y-6">
{reviews.map((review) => (
<div key={review.submission_id}>
{/* Contributor header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary">
{review.contributor_username}
</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
review.passed
? 'bg-emerald/10 text-emerald'
: 'bg-red-400/10 text-red-400'
}`}>
{review.passed ? 'Passed' : 'Failed'}
</span>
</div>
<span className="font-mono text-lg font-bold text-text-primary">
{review.overall_score.toFixed(1)}
<span className="text-text-muted text-xs font-mono">/10</span>
</span>
</div>

{/* LLM scores grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{review.scores.map((score) => (
<LLMScoreCard key={score.llm_name} score={score} />
))}
</div>
</div>
))}
</motion.div>
</motion.div>
);
}
21 changes: 21 additions & 0 deletions frontend/src/types/bounty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,24 @@ export interface EscrowVerifyResult {
amount_verified?: number;
error?: string;
}

export interface LLMReviewScore {
llm_name: string;
score: number;
max_score: number;
confidence: number;
quality: 'excellent' | 'good' | 'average' | 'poor';
summary: string;
strengths: string[];
improvements: string[];
reasoning: string;
}

export interface BountyReview {
submission_id: string;
contributor_username: string;
scores: LLMReviewScore[];
overall_score: number;
passed: boolean;
reviewed_at: string;
}
Loading