From c4e6ae4d9e92ef84f4f00b0b82fdb13d6705a2d5 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 12 Jan 2026 18:48:17 -0800 Subject: [PATCH] [Feat] UI Feedback Form - why LiteLLM (#18999) * init survey prompt * init survey modal * init Survey Modal * POST feedback hook * survey Modal * add other * in product survey fixes * fix survey prompt * fix survey --- ui/litellm-dashboard/src/app/page.tsx | 46 +++ .../src/components/survey/SurveyModal.tsx | 385 ++++++++++++++++++ .../src/components/survey/SurveyPrompt.tsx | 77 ++++ .../src/components/survey/index.tsx | 3 + 4 files changed, 511 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/survey/SurveyModal.tsx create mode 100644 ui/litellm-dashboard/src/components/survey/SurveyPrompt.tsx create mode 100644 ui/litellm-dashboard/src/components/survey/index.tsx diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 598266df9a..ac019a7a8c 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -27,6 +27,7 @@ import PromptsPanel from "@/components/prompts"; import PublicModelHub from "@/components/public_model_hub"; import { SearchTools } from "@/components/search_tools"; import Settings from "@/components/settings"; +import { SurveyPrompt, SurveyModal } from "@/components/survey"; import TagManagement from "@/components/tag_management"; import TransformRequestPanel from "@/components/transform_request"; import UIThemeSettings from "@/components/ui_theme_settings"; @@ -119,6 +120,10 @@ export default function CreateKeyPage() { const [authLoading, setAuthLoading] = useState(true); const [userID, setUserID] = useState(null); + // Survey state - always show by default + const [showSurveyPrompt, setShowSurveyPrompt] = useState(true); + const [showSurveyModal, setShowSurveyModal] = useState(false); + const invitation_id = searchParams.get("invitation_id"); // Get page from URL, default to 'api-keys' if not present @@ -262,6 +267,35 @@ export default function CreateKeyPage() { } }, [accessToken, userID, userRole]); + // Auto-dismiss survey prompt after 15 seconds + useEffect(() => { + if (showSurveyPrompt && !showSurveyModal) { + const timer = setTimeout(() => { + setShowSurveyPrompt(false); + }, 15000); + return () => clearTimeout(timer); + } + }, [showSurveyPrompt, showSurveyModal]); + + const handleOpenSurvey = () => { + setShowSurveyPrompt(false); + setShowSurveyModal(true); + }; + + const handleDismissSurveyPrompt = () => { + setShowSurveyPrompt(false); + }; + + const handleSurveyComplete = () => { + setShowSurveyModal(false); + }; + + const handleSurveyModalClose = () => { + // If they close the modal without completing, show the prompt again + setShowSurveyModal(false); + setShowSurveyPrompt(true); + }; + if (authLoading || redirectToLogin) { return ; } @@ -457,6 +491,18 @@ export default function CreateKeyPage() { /> )} + + {/* Survey Components */} + + )} diff --git a/ui/litellm-dashboard/src/components/survey/SurveyModal.tsx b/ui/litellm-dashboard/src/components/survey/SurveyModal.tsx new file mode 100644 index 0000000000..3a1dc68f19 --- /dev/null +++ b/ui/litellm-dashboard/src/components/survey/SurveyModal.tsx @@ -0,0 +1,385 @@ +import React, { useState } from "react"; +import { X, MessageSquare, ArrowRight, ArrowLeft } from "lucide-react"; +import { Button, Input, Radio, Space, Progress, Checkbox } from "antd"; + +interface SurveyModalProps { + isOpen: boolean; + onClose: () => void; + onComplete: () => void; +} + +const REASONS_OPTIONS = [ + { + id: "oss_adoption", + label: "OSS Adoption", + description: "Stars, contributors, forks, community support", + }, + { + id: "ai_integration", + label: "AI Integration", + description: "LiteLLM had the logging/guardrail integration we needed - Langfuse, OTEL, S3 logging, Azure Content Safety guardrails", + }, + { + id: "unified_api", + label: "Unified API", + description: "LiteLLM had the best OpenAI-compatible API across providers - OpenAI, Anthropic, Gemini, etc.", + }, + { + id: "breadth_of_models", + label: "Breadth of Models/Providers", + description: "LiteLLM had the provider + endpoint combinations we needed - /ocr endpoint with Mistral OCR, /batches endppint with Bedrock API, etc.", + }, + { + id: "other", + label: "Other", + description: "Something else not listed above", + }, +]; + +type SurveyData = { + usingAtCompany: boolean | null; + companyName: string; + startDate: string; + reasons: string[]; + otherReason: string; + email: string; +}; + +export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) { + const [step, setStep] = useState(1); + const [data, setData] = useState({ + usingAtCompany: null, + companyName: "", + startDate: "", + reasons: [], + otherReason: "", + email: "", + }); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Steps: 1=company?, 2=company name (conditional), 3=when, 4=why, 5=email + // If not at company: skip step 2, so total is 4 + // If at company: total is 5 + const totalSteps = data.usingAtCompany === true ? 5 : 4; + + if (!isOpen) return null; + + const handleNext = () => { + // Skip company name step if not using at company + if (step === 1 && data.usingAtCompany === false) { + setStep(3); // Skip to "when did you start" + } else if (step < 5) { + setStep(step + 1); + } else { + handleSubmit(); + } + }; + + const handleBack = () => { + if (step === 3 && data.usingAtCompany === false) { + setStep(1); // Go back to first question if we skipped company name + } else { + setStep(step - 1); + } + }; + + const handleSubmit = async () => { + setIsSubmitting(true); + try { + // Map reason IDs to readable labels + const reasonLabels: Record = { + oss_adoption: "OSS Adoption (stars, contributors, forks)", + ai_integration: "AI Integration (Langfuse, OTEL, S3, Azure Content Safety)", + unified_api: "Unified API (OpenAI-compatible)", + breadth_of_models: "Breadth of Models/Providers (/ocr, /batches, Bedrock, Azure OCR)", + }; + + const readableReasons = data.reasons.map((r) => { + if (r === "other" && data.otherReason) { + return `Other: ${data.otherReason}`; + } + return reasonLabels[r] || r; + }); + + await fetch("https://hooks.zapier.com/hooks/catch/16331268/ugms6w0/", { + method: "POST", + mode: "no-cors", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + usingAtCompany: data.usingAtCompany ? "Yes" : "No", + companyName: data.companyName || null, + startDate: data.startDate, + reasons: readableReasons.join(", "), + otherReason: data.otherReason || null, + email: data.email || null, + submittedAt: new Date().toISOString(), + }), + }); + } catch (error) { + // Silently fail - don't block the user experience + console.error("Failed to submit survey:", error); + } + setIsSubmitting(false); + onComplete(); + }; + + const updateData = (key: keyof SurveyData, value: boolean | string | string[] | null) => { + setData((prev) => ({ + ...prev, + [key]: value, + })); + }; + + const toggleReason = (reasonId: string) => { + setData((prev) => ({ + ...prev, + reasons: prev.reasons.includes(reasonId) + ? prev.reasons.filter((r) => r !== reasonId) + : [...prev.reasons, reasonId], + })); + }; + + const isStepValid = () => { + if (step === 1) return data.usingAtCompany !== null; + if (step === 2) return data.companyName.trim().length > 0; + if (step === 3) return data.startDate !== ""; + if (step === 4) { + // If "other" is selected, require the text field + if (data.reasons.includes("other")) { + return data.reasons.length > 0 && data.otherReason.trim().length > 0; + } + return data.reasons.length > 0; + } + if (step === 5) return true; // Email is optional + return false; + }; + + const getStepNumber = () => { + if (data.usingAtCompany === false) { + // When not at company: skip step 2, so steps 3,4,5 become 2,3,4 + if (step === 1) return 1; + if (step === 3) return 2; + if (step === 4) return 3; + if (step === 5) return 4; + } + return step; + }; + + const renderStepContent = () => { + // Step 1: Using at company? + if (step === 1) { + return ( +
+

Are you using LiteLLM at your company?

+

Help us understand how our product is being used in professional environments.

+
+ + +
+
+ ); + } + + // Step 2: Company name (only if using at company) + if (step === 2 && data.usingAtCompany === true) { + return ( +
+

What company are you using LiteLLM at?

+

This helps us understand our user base better.

+ updateData("companyName", e.target.value)} + autoFocus + /> +
+ ); + } + + // Step 3: When did you start? + if (step === 3) { + return ( +
+

When did you start using LiteLLM?

+ updateData("startDate", e.target.value)} + className="w-full" + > + + {["Less than a month ago", "1-3 months ago", "3-6 months ago", "More than 6 months ago"].map((option) => ( + + ))} + + +
+ ); + } + + // Step 4: Why did you pick LiteLLM? + if (step === 4) { + return ( +
+

Why did you pick LiteLLM over other AI Gateways?

+

Select all that apply.

+
+ {REASONS_OPTIONS.map((option) => { + const isSelected = data.reasons.includes(option.id); + return ( +
+
toggleReason(option.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleReason(option.id); + } + }} + className={`flex items-start p-4 rounded-lg border cursor-pointer transition-all ${ + isSelected + ? "border-blue-600 bg-blue-50 ring-1 ring-blue-600" + : "border-gray-200 hover:bg-gray-50" + }`} + > + +
+ {option.label} + {option.description} +
+
+ {/* Show text input if "Other" is selected */} + {option.id === "other" && isSelected && ( + updateData("otherReason", e.target.value)} + onClick={(e) => e.stopPropagation()} + autoFocus + /> + )} +
+ ); + })} +
+
+ ); + } + + // Step 5: Email (optional) + if (step === 5) { + return ( +
+

Want to share more?

+

+ Leave your email and we may reach out to learn more about your experience. This is completely optional. +

+ updateData("email", e.target.value)} + autoFocus + /> +

+ We will only use this to follow up on your feedback. No spam, ever. +

+
+ ); + } + + return null; + }; + + const isLastStep = step === 5; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+ + Quick Feedback +
+ +
+ + {/* Progress Bar */} + + + {/* Content */} +
{renderStepContent()}
+ + {/* Footer */} +
+
+ Step {getStepNumber()} of {totalSteps} +
+
+ {step > 1 && ( + + )} + +
+
+
+
+ ); +} + diff --git a/ui/litellm-dashboard/src/components/survey/SurveyPrompt.tsx b/ui/litellm-dashboard/src/components/survey/SurveyPrompt.tsx new file mode 100644 index 0000000000..69a886bdde --- /dev/null +++ b/ui/litellm-dashboard/src/components/survey/SurveyPrompt.tsx @@ -0,0 +1,77 @@ +import React, { useEffect, useState } from "react"; +import { MessageSquare, X } from "lucide-react"; +import { Button } from "antd"; + +interface SurveyPromptProps { + onOpen: () => void; + onDismiss: () => void; + isVisible: boolean; +} + +const DISMISS_DURATION = 15000; // 15 seconds + +export function SurveyPrompt({ onOpen, onDismiss, isVisible }: SurveyPromptProps) { + const [progress, setProgress] = useState(100); + + useEffect(() => { + if (!isVisible) { + setProgress(100); + return; + } + + const startTime = Date.now(); + const interval = setInterval(() => { + const elapsed = Date.now() - startTime; + const remaining = Math.max(0, 100 - (elapsed / DISMISS_DURATION) * 100); + setProgress(remaining); + + if (remaining <= 0) { + clearInterval(interval); + } + }, 50); + + return () => clearInterval(interval); + }, [isVisible]); + + if (!isVisible) return null; + + return ( +
+ {/* Progress bar at top showing time remaining */} +
+
+
+ +
+
+
+ + Quick feedback +
+ +
+ +

+ Help us improve LiteLLM! Share your experience in 5 quick questions. +

+ + +
+
+ ); +} + diff --git a/ui/litellm-dashboard/src/components/survey/index.tsx b/ui/litellm-dashboard/src/components/survey/index.tsx new file mode 100644 index 0000000000..7c36419980 --- /dev/null +++ b/ui/litellm-dashboard/src/components/survey/index.tsx @@ -0,0 +1,3 @@ +export { SurveyPrompt } from "./SurveyPrompt"; +export { SurveyModal } from "./SurveyModal"; +