From 424ddcc074ddb5499aab9d8b4fc1ca59766ce528 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Wed, 5 Aug 2026 02:09:12 +0800 Subject: [PATCH] feat: add LLM review results display to bounty detail page - Add BountyReview and LLMReviewScore types - Add getBountyReviews API function - Create LLMReviewCard component with per-LLM score cards - Score bar, confidence badge, quality indicator - Expandable section with strengths, improvements, reasoning - Side-by-side display for Claude, Codex, Gemini - Integrate into BountyDetail page with loading/error states Closes #837 [fj4WqyCCw3C5ShR1RfB7MoBPTpkRrBFYP1uT35g3MvT] --- frontend/src/api/bounties.ts | 5 + .../src/components/bounty/BountyDetail.tsx | 45 +++- .../src/components/bounty/LLMReviewCard.tsx | 193 ++++++++++++++++++ frontend/src/types/bounty.ts | 21 ++ 4 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/bounty/LLMReviewCard.tsx diff --git a/frontend/src/api/bounties.ts b/frontend/src/api/bounties.ts index 921a65ebd..1015500ec 100644 --- a/frontend/src/api/bounties.ts +++ b/frontend/src/api/bounties.ts @@ -6,6 +6,7 @@ import type { TreasuryDepositInfo, EscrowVerifyPayload, EscrowVerifyResult, + BountyReview, } from '../types/bounty'; export interface BountiesListParams { @@ -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 { + return apiClient(`/api/bounties/${bountyId}/reviews`); +} diff --git a/frontend/src/components/bounty/BountyDetail.tsx b/frontend/src/components/bounty/BountyDetail.tsx index 65653fa8f..5204b88cc 100644 --- a/frontend/src/components/bounty/BountyDetail.tsx +++ b/frontend/src/components/bounty/BountyDetail.tsx @@ -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 { @@ -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([]); + 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(() => { @@ -111,6 +134,24 @@ export function BountyDetail({ bounty }: BountyDetailProps) { )} ) : null} + + {/* LLM Review Results */} + {reviewsLoading && ( +
+
+ + Loading AI review results... +
+
+ )} + {reviewsError && ( +
+

AI review results unavailable.

+
+ )} + {!reviewsLoading && !reviewsError && reviews.length > 0 && ( + + )} {/* Sidebar */} diff --git a/frontend/src/components/bounty/LLMReviewCard.tsx b/frontend/src/components/bounty/LLMReviewCard.tsx new file mode 100644 index 000000000..d50e4e2dd --- /dev/null +++ b/frontend/src/components/bounty/LLMReviewCard.tsx @@ -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 = { + 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 = { + 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 ( +
+ +
+ ); +} + +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 ( + + {/* Header */} +
+
+
+ +
+
+ {meta.label} +
+ Confidence + {Math.round(score.confidence * 100)}% +
+
+
+
+ + {score.score.toFixed(1)} + + /{score.max_score} +
+
+ + {/* Score bar */} + + + {/* Quality badge */} +
+ + {score.quality} + + {score.summary} +
+ + {/* Expand/collapse */} + + + {/* Expanded details */} + {expanded && ( + + {/* Strengths */} + {score.strengths.length > 0 && ( +
+
+ Strengths +
+
    + {score.strengths.map((s, i) => ( +
  • + + {s} +
  • + ))} +
+
+ )} + + {/* Improvements */} + {score.improvements.length > 0 && ( +
+
+ Suggested Improvements +
+
    + {score.improvements.map((s, i) => ( +
  • + + {s} +
  • + ))} +
+
+ )} + + {/* Reasoning */} +
+
+ Reasoning +
+

{score.reasoning}

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

+ AI Review Results +

+ + + {reviews.map((review) => ( +
+ {/* Contributor header */} +
+
+ + {review.contributor_username} + + + {review.passed ? 'Passed' : 'Failed'} + +
+ + {review.overall_score.toFixed(1)} + /10 + +
+ + {/* LLM scores grid */} +
+ {review.scores.map((score) => ( + + ))} +
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/types/bounty.ts b/frontend/src/types/bounty.ts index 4930ad861..e55cb6a27 100644 --- a/frontend/src/types/bounty.ts +++ b/frontend/src/types/bounty.ts @@ -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; +}