diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts new file mode 100644 index 000000000..ddf3545b1 --- /dev/null +++ b/frontend/src/api/ai.ts @@ -0,0 +1,58 @@ +/** + * AI Description Enhancement API + * @module api/ai + */ + +import { apiClient } from '../services/apiClient'; + +export interface DescriptionEnhanceRequest { + title: string; + description: string; + tier?: string; + provider?: 'claude' | 'openai' | 'gemini'; +} + +export interface EnhancedDescription { + title: string; + description: string; + acceptance_criteria: string[]; + suggested_skills: string[]; + suggested_tier: string; + provider: string; + confidence: number; +} + +export interface MultiLLMResult { + claude: EnhancedDescription | null; + openai: EnhancedDescription | null; + gemini: EnhancedDescription | null; + consensus: EnhancedDescription; +} + +/** + * Enhance a bounty description using AI. + * Calls the backend endpoint which orchestrates multi-LLM analysis. + */ +export async function enhanceDescription( + payload: DescriptionEnhanceRequest, +): Promise { + return apiClient('/api/ai/enhance-description', { + method: 'POST', + body: payload, + timeoutMs: 30_000, + }); +} + +/** + * Quick single-provider enhancement (lighter weight). + */ +export async function enhanceDescriptionQuick( + title: string, + description: string, +): Promise { + return apiClient('/api/ai/enhance-description/quick', { + method: 'POST', + body: { title, description }, + timeoutMs: 15_000, + }); +} diff --git a/frontend/src/components/bounty/AIDescriptionEnhancer.tsx b/frontend/src/components/bounty/AIDescriptionEnhancer.tsx new file mode 100644 index 000000000..a957106b0 --- /dev/null +++ b/frontend/src/components/bounty/AIDescriptionEnhancer.tsx @@ -0,0 +1,274 @@ +import React, { useState, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Sparkles, Loader2, Check, ChevronDown, AlertCircle, Bot } from 'lucide-react'; +import type { EnhancedDescription } from '../../api/ai'; +import { fadeIn } from '../../lib/animations'; + +interface AIDescriptionEnhancerProps { + title: string; + description: string; + onApply: (enhanced: EnhancedDescription) => void; + disabled?: boolean; +} + +const PROVIDER_STYLES: Record = { + claude: { label: 'Claude', color: 'text-orange-400', bg: 'bg-orange-400/10', border: 'border-orange-400/20' }, + openai: { label: 'OpenAI', color: 'text-emerald-400', bg: 'bg-emerald-400/10', border: 'border-emerald-400/20' }, + gemini: { label: 'Gemini', color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20' }, +}; + +export function AIDescriptionEnhancer({ title, description, onApply, disabled }: AIDescriptionEnhancerProps) { + const [enhancing, setEnhancing] = useState(false); + const [error, setError] = useState(null); + const [results, setResults] = useState(null); + const [selected, setSelected] = useState(0); + const [showDetails, setShowDetails] = useState(false); + + const canEnhance = title.trim().length >= 5 && description.trim().length >= 20; + + const handleEnhance = useCallback(async () => { + if (!canEnhance || enhancing) return; + setEnhancing(true); + setError(null); + setResults(null); + setSelected(0); + setShowDetails(false); + + try { + const { enhanceDescription } = await import('../../api/ai'); + const response = await enhanceDescription({ title, description }); + + // Collect non-null provider results + const providerResults: EnhancedDescription[] = []; + if (response.claude) providerResults.push(response.claude); + if (response.openai) providerResults.push(response.openai); + if (response.gemini) providerResults.push(response.gemini); + + // Always include consensus as first option + if (response.consensus) { + providerResults.unshift({ + ...response.consensus, + provider: 'consensus', + confidence: Math.max( + ...providerResults.map((r) => r.confidence), + response.consensus.confidence, + ), + }); + } + + setResults(providerResults.length > 0 ? providerResults : null); + } catch (err) { + setError(err instanceof Error ? err.message : 'AI enhancement failed. Try again.'); + } finally { + setEnhancing(false); + } + }, [title, description, canEnhance, enhancing]); + + const handleApply = useCallback(() => { + if (results && results[selected]) { + onApply(results[selected]); + setResults(null); + } + }, [results, selected, onApply]); + + const handleDismiss = useCallback(() => { + setResults(null); + setError(null); + }, []); + + return ( +
+ {/* Enhance button */} + + + {canEnhance && !enhancing && !results && ( +

+ Uses Claude, OpenAI & Gemini to improve clarity, add acceptance criteria, and suggest skills. +

+ )} + + {/* Error state */} + {error && ( + + +
+

{error}

+ +
+
+ )} + + {/* Results */} + + {results && results.length > 0 && ( + + {/* Header */} +
+
+ + AI Enhancement Results + ({results.length} suggestions) +
+ +
+ + {/* Provider tabs */} +
+ {results.map((result, i) => { + const style = + result.provider === 'consensus' + ? { label: '⭐ Consensus', color: 'text-yellow-400', bg: 'bg-yellow-400/10', border: 'border-yellow-400/20' } + : PROVIDER_STYLES[result.provider] ?? { label: result.provider, color: 'text-text-secondary', bg: 'bg-forge-700', border: 'border-border' }; + const isActive = selected === i; + return ( + + ); + })} +
+ + {/* Selected result preview */} + {results[selected] && ( +
+
+

Enhanced Title

+

{results[selected].title}

+
+ +
+

Enhanced Description

+

+ {results[selected].description} +

+
+ + + + {showDetails && ( + + {/* Full description */} +
+

Full Description

+

+ {results[selected].description} +

+
+ + {/* Acceptance criteria */} + {results[selected].acceptance_criteria.length > 0 && ( +
+

Acceptance Criteria

+
    + {results[selected].acceptance_criteria.map((criterion, i) => ( +
  • + + {criterion} +
  • + ))} +
+
+ )} + + {/* Suggested skills */} + {results[selected].suggested_skills.length > 0 && ( +
+

Suggested Skills

+
+ {results[selected].suggested_skills.map((skill) => ( + + {skill} + + ))} +
+
+ )} + + {/* Suggested tier */} +
+

Suggested Tier

+ + {results[selected].suggested_tier} + +
+
+ )} + + {/* Apply button */} +
+ +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/bounty/BountyCreateWizard.tsx b/frontend/src/components/bounty/BountyCreateWizard.tsx index 0c4c0d76d..7e04f0308 100644 --- a/frontend/src/components/bounty/BountyCreateWizard.tsx +++ b/frontend/src/components/bounty/BountyCreateWizard.tsx @@ -1,10 +1,12 @@ import React, { useState, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; -import { Check, ChevronRight, Loader2, Copy } from 'lucide-react'; +import { Check, ChevronRight, Loader2, Copy, Sparkles } from 'lucide-react'; import type { BountyCreatePayload } from '../../types/bounty'; +import type { EnhancedDescription } from '../../api/ai'; import { createBounty, getTreasuryDepositInfo, verifyEscrowDeposit } from '../../api/bounties'; import { pageTransition } from '../../lib/animations'; +import { AIDescriptionEnhancer } from './AIDescriptionEnhancer'; const PRESET_AMOUNTS = [10, 20, 50, 100, 200]; const PLATFORM_FEE_PCT = 0.05; @@ -72,6 +74,16 @@ function Step1({ }) { const canProceed = state.title.trim().length >= 5 && state.description.trim().length >= 20; + const handleApplyEnhancement = (enhanced: EnhancedDescription) => { + onChange('title', enhanced.title); + // Append acceptance criteria to description if available + let desc = enhanced.description; + if (enhanced.acceptance_criteria.length > 0) { + desc += '\n\n## Acceptance Criteria\n' + enhanced.acceptance_criteria.map((c) => `- ${c}`).join('\n'); + } + onChange('description', desc); + }; + return (
@@ -87,7 +99,14 @@ function Step1({ />
- +
+ + +