diff --git a/app/course/[course_id]/UserMenu.tsx b/app/course/[course_id]/UserMenu.tsx index 052c1f91..6cea5194 100644 --- a/app/course/[course_id]/UserMenu.tsx +++ b/app/course/[course_id]/UserMenu.tsx @@ -41,6 +41,7 @@ import { RiChatSettingsFill } from "react-icons/ri"; import { TbSpy, TbSpyOff } from "react-icons/tb"; import { signOutAction } from "../../actions"; import { LuCopy, LuCheck } from "react-icons/lu"; +import MCPTokensMenu from "@/components/settings/MCPTokensMenu"; function SupportMenu() { // Track whether the build number has been successfully copied @@ -699,6 +700,7 @@ function UserSettingsMenu() { + + + + + + + ); +} + +/** + * AIHelpButton component for launching AI assistance context + * + * This component provides a button that instructors and graders can use + * to get AI assistance when helping students. It generates MCP context + * that can be used with any MCP-compatible AI assistant. + */ +export function AIHelpButton({ + contextType, + resourceId, + classId, + assignmentId, + submissionId, + size = "sm", + variant = "outline" +}: AIHelpButtonProps) { + const isInstructorOrGrader = useIsGraderOrInstructor(); + const [showContext, setShowContext] = useState(false); + const [showFeedback, setShowFeedback] = useState(false); + const [showSetupDialog, setShowSetupDialog] = useState(false); + const [hasTokens, setHasTokens] = useState(null); // null = not checked yet + + // Check if user has any active MCP tokens + useEffect(() => { + if (!isInstructorOrGrader) return; + + async function checkTokens() { + try { + const supabase = createClient(); + const { tokens } = await mcpTokensList(supabase); + // Check for at least one non-revoked, non-expired token + const now = new Date(); + const hasActiveToken = tokens?.some((t) => !t.revoked_at && new Date(t.expires_at) > now); + setHasTokens(hasActiveToken ?? false); + } catch { + // If we can't check, assume they might have tokens + setHasTokens(true); + } + } + + checkTokens(); + }, [isInstructorOrGrader]); + + const prompt = useMemo( + () => + generateAIPrompt({ + contextType, + resourceId, + classId, + assignmentId, + submissionId + }), + [contextType, resourceId, classId, assignmentId, submissionId] + ); + + const handleCopyContext = useCallback(async () => { + try { + await navigator.clipboard.writeText(prompt); + toaster.success({ + title: "Copied AI context", + description: "The AI help prompt has been copied to your clipboard." + }); + // Show feedback panel after copying + setShowContext(false); + setShowFeedback(true); + } catch { + toaster.error({ + title: "Failed to copy", + description: "Could not copy to clipboard. Please try again." + }); + } + }, [prompt]); + + const handleClose = useCallback(() => { + setShowContext(false); + setShowFeedback(false); + setShowSetupDialog(false); + }, []); + + const handleButtonClick = useCallback(() => { + if (hasTokens === false) { + setShowSetupDialog(true); + } else { + setShowContext(true); + } + }, [hasTokens]); + + // Only show for instructors/graders + if (!isInstructorOrGrader) { + return null; + } + + if (showSetupDialog) { + return ; + } + + if (showFeedback) { + return ( + + ); + } + + if (showContext) { + return ( + + + + + + AI Help Context + + + + + + + + Copy this prompt to use with Claude, ChatGPT, or any MCP-compatible AI assistant: + + + + + + + + + + ); + } + + return ( + + + + ); +} + +/** + * Compact icon-only version of the AI Help button + */ +export function AIHelpIconButton({ + contextType, + resourceId, + classId, + assignmentId, + submissionId +}: Omit) { + const isInstructorOrGrader = useIsGraderOrInstructor(); + const [showFeedback, setShowFeedback] = useState(false); + const [showSetupDialog, setShowSetupDialog] = useState(false); + const [hasTokens, setHasTokens] = useState(null); + + // Check if user has any active MCP tokens + useEffect(() => { + if (!isInstructorOrGrader) return; + + async function checkTokens() { + try { + const supabase = createClient(); + const { tokens } = await mcpTokensList(supabase); + const now = new Date(); + const hasActiveToken = tokens?.some((t) => !t.revoked_at && new Date(t.expires_at) > now); + setHasTokens(hasActiveToken ?? false); + } catch { + setHasTokens(true); + } + } + + checkTokens(); + }, [isInstructorOrGrader]); + + const prompt = useMemo( + () => + generateAIPrompt({ + contextType, + resourceId, + classId, + assignmentId, + submissionId + }), + [contextType, resourceId, classId, assignmentId, submissionId] + ); + + const handleClick = useCallback(async () => { + // Show setup dialog if no tokens + if (hasTokens === false) { + setShowSetupDialog(true); + return; + } + + try { + await navigator.clipboard.writeText(prompt); + toaster.success({ + title: "Copied AI context", + description: "Paste this prompt into your AI assistant to get help." + }); + // Show feedback after copying + setShowFeedback(true); + } catch { + toaster.error({ + title: "Failed to copy", + description: "Could not copy to clipboard." + }); + } + }, [prompt, hasTokens]); + + const handleClose = useCallback(() => { + setShowFeedback(false); + setShowSetupDialog(false); + }, []); + + // Only show for instructors/graders + if (!isInstructorOrGrader) { + return null; + } + + if (showSetupDialog) { + return ; + } + + if (showFeedback) { + return ( + + ); + } + + return ( + + + + + + ); +} + +export default AIHelpButton; diff --git a/components/ai-help/AIHelpFeedbackPanel.tsx b/components/ai-help/AIHelpFeedbackPanel.tsx new file mode 100644 index 00000000..025246b0 --- /dev/null +++ b/components/ai-help/AIHelpFeedbackPanel.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { toaster } from "@/components/ui/toaster"; +import { aiHelpFeedbackSubmit } from "@/lib/edgeFunctions"; +import { createClient } from "@/utils/supabase/client"; +import { Box, HStack, Icon, IconButton, Text, Textarea, VStack } from "@chakra-ui/react"; +import { useState } from "react"; +import { BsX } from "react-icons/bs"; +import { LuThumbsUp, LuThumbsDown } from "react-icons/lu"; + +export type AIHelpContextType = "help_request" | "discussion_thread" | "test_failure" | "build_error" | "test_insights"; + +interface AIHelpFeedbackPanelProps { + /** Class ID for authorization */ + classId: number; + /** Type of context being analyzed */ + contextType: AIHelpContextType; + /** Resource ID (submission ID for test_failure/build_error, thread/request ID for others) */ + resourceId: number; + /** Callback when panel is closed */ + onClose: () => void; +} + +/** + * Submit feedback via RPC + */ +async function submitFeedback( + classId: number, + contextType: AIHelpContextType, + resourceId: number, + rating: "thumbs_up" | "thumbs_down", + comment?: string +): Promise { + try { + const supabase = createClient(); + await aiHelpFeedbackSubmit( + { + class_id: classId, + context_type: contextType, + resource_id: resourceId, + rating, + comment: comment || undefined + }, + supabase + ); + return true; + } catch { + return false; + } +} + +/** + * Shared feedback panel shown after copying AI context. + * Used by all AI help buttons to collect user feedback. + */ +export function AIHelpFeedbackPanel({ classId, contextType, resourceId, onClose }: AIHelpFeedbackPanelProps) { + const [rating, setRating] = useState<"thumbs_up" | "thumbs_down" | null>(null); + const [comment, setComment] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + + const handleSubmit = async () => { + if (!rating) return; + + setSubmitting(true); + const success = await submitFeedback(classId, contextType, resourceId, rating, comment); + setSubmitting(false); + + if (success) { + setSubmitted(true); + toaster.success({ + title: "Thanks for your feedback!", + description: "Your feedback helps us improve the AI assistance feature." + }); + } else { + toaster.error({ + title: "Failed to submit feedback", + description: "Please try again later." + }); + } + }; + + if (submitted) { + return ( + + + + Thank you for your feedback! + + + + + + + ); + } + + return ( + + + + How was the AI assistance? + + + + + + + + + setRating("thumbs_up")} + > + + + setRating("thumbs_down")} + > + + + + +