diff --git a/components/ai/AiAssumptionsBanner.tsx b/components/ai/AiAssumptionsBanner.tsx new file mode 100644 index 00000000..75c59be5 --- /dev/null +++ b/components/ai/AiAssumptionsBanner.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useState } from 'react'; +import { Sparkles, X } from 'lucide-react'; + +import { BoundlessButton } from '@/components/buttons'; + +export interface AiAssumption { + section: string; + field: string; + note: string; +} + +interface AiAssumptionsBannerProps { + assumptions: AiAssumption[]; + /** Jump to the wizard step that owns an assumption so the organizer can edit. */ + onReview?: (section: string) => void; + className?: string; +} + +/** + * Shows the non-obvious choices an AI draft made ("Assumed a single winner…") + * so the organizer can see and correct every guess. Shared by the bounty and + * hackathon review steps. Dismissible; renders nothing when there's nothing to + * surface. + */ +export default function AiAssumptionsBanner({ + assumptions, + onReview, + className, +}: AiAssumptionsBannerProps) { + const [dismissed, setDismissed] = useState(false); + if (dismissed || !assumptions || assumptions.length === 0) return null; + + return ( +
+
+
+ + + +
+

+ A few things the AI assumed +

+

+ Review these guesses and correct anything that doesn't match + what you meant. +

+
+
+ +
+ + +
+ ); +} diff --git a/components/ai/AiBriefPanel.tsx b/components/ai/AiBriefPanel.tsx new file mode 100644 index 00000000..9001e9b0 --- /dev/null +++ b/components/ai/AiBriefPanel.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useState } from 'react'; +import { ChevronDown, FileText } from 'lucide-react'; + +interface AiBriefPanelProps { + /** The brief that produced the AI draft (may include folded-in clarify answers). */ + brief?: string | null; + className?: string; +} + +/** + * Collapsible "Your brief" panel shown beside an AI-generated draft on the review + * step, so the organizer can compare what they asked for against what the AI + * produced. Renders nothing for manually-created drafts. Shared by both wizards. + */ +export default function AiBriefPanel({ brief, className }: AiBriefPanelProps) { + const [open, setOpen] = useState(false); + if (!brief || brief.trim() === '') return null; + + return ( +
+ + {open && ( +
+

{brief}

+
+ )} +
+ ); +} diff --git a/components/ai/AiClarifyQuestions.tsx b/components/ai/AiClarifyQuestions.tsx new file mode 100644 index 00000000..26d395f3 --- /dev/null +++ b/components/ai/AiClarifyQuestions.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useState } from 'react'; +import { Sparkles } from 'lucide-react'; + +import { BoundlessButton } from '@/components/buttons'; + +export interface ClarifyQuestionOption { + value: string; + label: string; +} +export interface ClarifyQuestionItem { + id: string; + question: string; + options: ClarifyQuestionOption[]; +} + +/** A picked answer, ready to fold into the brief. */ +export interface ClarifyAnswer { + question: string; + label: string; +} + +interface AiClarifyQuestionsProps { + questions: ClarifyQuestionItem[]; + /** Continue to drafting with the chosen answers (skipped questions omitted). */ + onSubmit: (answers: ClarifyAnswer[]) => void; + /** Draft now without answering (the AI picks sensible defaults). */ + onSkip: () => void; + isSubmitting?: boolean; +} + +/** + * Adaptive clarify step shared by the bounty + hackathon generate dialogs. + * Shows 1-3 chip questions when the brief was too thin; answers are folded back + * into the brief before drafting. Answering is optional — Skip drafts straight + * away. + */ +export default function AiClarifyQuestions({ + questions, + onSubmit, + onSkip, + isSubmitting = false, +}: AiClarifyQuestionsProps) { + const [selected, setSelected] = useState>({}); + + const handleContinue = () => { + const answers: ClarifyAnswer[] = questions + .filter(q => selected[q.id]) + .map(q => ({ + question: q.question, + label: + q.options.find(o => o.value === selected[q.id])?.label ?? + selected[q.id], + })); + onSubmit(answers); + }; + + return ( +
+
+ + + +

+ A couple of quick questions so the draft matches what you have in + mind. Answer what you can — you can skip the rest. +

+
+ +
+ {questions.map(q => ( +
+

{q.question}

+
+ {q.options.map(o => { + const on = selected[q.id] === o.value; + return ( + + ); + })} +
+
+ ))} +
+ +
+ + Skip + + } + iconPosition='left' + onClick={handleContinue} + > + Generate draft + +
+
+ ); +} diff --git a/components/ai/AiExampleReference.tsx b/components/ai/AiExampleReference.tsx new file mode 100644 index 00000000..23a3a20b --- /dev/null +++ b/components/ai/AiExampleReference.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +export interface ExampleItem { + id: string; + label: string; + /** The text fed to the model as a style reference when this item is picked. */ + example: string; +} + +interface AiExampleReferenceProps { + items: ExampleItem[]; + /** Selected item id, or null for none. */ + value: string | null; + onChange: (id: string | null) => void; + /** e.g. "bounty" | "hackathon". */ + noun: string; +} + +const NONE = '__none__'; + +/** + * Optional "use a past one as a style reference" picker for the Generate + * dialogs. The chosen item's gist is passed to the model as `examples[]` so the + * draft mirrors the organizer's house style. Renders nothing when there's + * nothing to reference. Shared by both wizards. + */ +export default function AiExampleReference({ + items, + value, + onChange, + noun, +}: AiExampleReferenceProps) { + if (items.length === 0) return null; + + return ( +
+

+ Match a past {noun}{' '} + (optional) +

+ +

+ We'll use its style as a reference — your brief still drives the + content. +

+
+ ); +} diff --git a/components/ai/AiRegenerateControl.tsx b/components/ai/AiRegenerateControl.tsx new file mode 100644 index 00000000..cfc8cd18 --- /dev/null +++ b/components/ai/AiRegenerateControl.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { useState } from 'react'; +import { Sparkles } from 'lucide-react'; +import { toast } from 'sonner'; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Textarea } from '@/components/ui/textarea'; +import { BoundlessButton } from '@/components/buttons'; +import { ApiError } from '@/lib/api'; + +type RegenData = Record; + +interface AiRegenerateControlProps { + /** Render only on AI-generated drafts. */ + available: boolean; + /** True while the regenerate request is in flight. */ + isRunning: boolean; + /** Run the regenerate; resolve with the proposed values (wizard section shape). */ + onRun: (instructions: string) => Promise; + /** Apply the accepted values to the tab's form. */ + onApply: (data: RegenData) => void; + label?: string; + /** Optional custom preview of the proposed values. */ + summarize?: (data: RegenData) => string; + /** Pre-fill the instructions box (e.g. after a mode change). */ + defaultInstruction?: string; +} + +const truncate = (s: string, n = 80): string => + s.length > n ? `${s.slice(0, n)}…` : s; + +/** Default compact preview: one line per changed field. */ +function defaultSummary(data: RegenData): string { + return Object.entries(data) + .map(([k, v]) => { + if (Array.isArray(v)) return `${k}: ${v.length} item(s)`; + if (v && typeof v === 'object') return `${k}: updated`; + return `${k}: ${truncate(String(v ?? ''))}`; + }) + .join('\n'); +} + +/** + * Steerable, non-destructive "Regenerate with AI" control shared by the bounty + * and hackathon wizards. The organizer can add a short instruction ("make the + * deadline 3 weeks"), runs it, then **previews** the proposed values and chooses + * Apply or Discard — so a regenerate never silently overwrites in-progress edits. + */ +export default function AiRegenerateControl({ + available, + isRunning, + onRun, + onApply, + label = 'Regenerate with AI', + summarize, + defaultInstruction, +}: AiRegenerateControlProps) { + const [open, setOpen] = useState(false); + const [instructions, setInstructions] = useState(defaultInstruction ?? ''); + const [proposed, setProposed] = useState(null); + + if (!available) return null; + + const reset = () => { + setProposed(null); + setInstructions(defaultInstruction ?? ''); + }; + + const handleRun = async () => { + try { + const data = await onRun(instructions.trim()); + if (!data || Object.keys(data).length === 0) { + toast.message('No changes were proposed. Try a different instruction.'); + return; + } + setProposed(data); + } catch (err) { + if (err instanceof ApiError) { + if (err.status === 503) { + toast.error('The AI assistant is busy. Try again in a moment.'); + return; + } + toast.error(err.message || 'Could not regenerate this section.'); + return; + } + toast.error('Could not regenerate this section.'); + } + }; + + const handleApply = () => { + if (proposed) onApply(proposed); + toast.success('Applied. Review the new values.'); + setOpen(false); + reset(); + }; + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + + + + {label} + + + + {proposed === null ? ( +
+
+

Regenerate with AI

+

+ Optionally tell the AI what to change. +

+
+