diff --git a/apps/webapp/app/components/AlphaBadge.tsx b/apps/webapp/app/components/AlphaBadge.tsx new file mode 100644 index 0000000000..58da1a994c --- /dev/null +++ b/apps/webapp/app/components/AlphaBadge.tsx @@ -0,0 +1,32 @@ +import { cn } from "~/utils/cn"; +import { Badge } from "./primitives/Badge"; +import { SimpleTooltip } from "./primitives/Tooltip"; + +export function AlphaBadge({ + inline = false, + className, +}: { + inline?: boolean; + className?: string; +}) { + return ( + + Alpha + + } + content="This feature is in Alpha." + disableHoverableContent + /> + ); +} + +export function AlphaTitle({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + + ); +} diff --git a/apps/webapp/app/components/code/AIQueryInput.tsx b/apps/webapp/app/components/code/AIQueryInput.tsx new file mode 100644 index 0000000000..e3e3ed9c7b --- /dev/null +++ b/apps/webapp/app/components/code/AIQueryInput.tsx @@ -0,0 +1,401 @@ +import { PencilSquareIcon, PlusIcon, SparklesIcon } from "@heroicons/react/20/solid"; +import { AnimatePresence, motion } from "framer-motion"; +import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react"; +import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; + +// Lazy load streamdown components to avoid SSR issues +const StreamdownRenderer = lazy(() => + import("streamdown").then((mod) => ({ + default: ({ children, isAnimating }: { children: string; isAnimating: boolean }) => ( + + {children} + + ), + })) +); +import { Button } from "~/components/primitives/Buttons"; +import { Spinner } from "~/components/primitives/Spinner"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { cn } from "~/utils/cn"; + +type StreamEventType = + | { type: "thinking"; content: string } + | { type: "tool_call"; tool: string; args: unknown } + | { type: "result"; success: true; query: string } + | { type: "result"; success: false; error: string }; + +export type AIQueryMode = "new" | "edit"; + +interface AIQueryInputProps { + onQueryGenerated: (query: string) => void; + /** Set this to a prompt to auto-populate and immediately submit */ + autoSubmitPrompt?: string; + /** Get the current query in the editor (used for edit mode) */ + getCurrentQuery?: () => string; +} + +export function AIQueryInput({ + onQueryGenerated, + autoSubmitPrompt, + getCurrentQuery, +}: AIQueryInputProps) { + const [prompt, setPrompt] = useState(""); + const [mode, setMode] = useState("new"); + const [isLoading, setIsLoading] = useState(false); + const [thinking, setThinking] = useState(""); + const [error, setError] = useState(null); + const [showThinking, setShowThinking] = useState(false); + const [lastResult, setLastResult] = useState<"success" | "error" | null>(null); + const textareaRef = useRef(null); + const abortControllerRef = useRef(null); + const lastAutoSubmitRef = useRef(null); + + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-generate`; + + // Can only use edit mode if there's a current query + const canEdit = Boolean(getCurrentQuery?.()?.trim()); + + // If mode is edit but there's no current query, switch to new + useEffect(() => { + if (mode === "edit" && !canEdit) { + setMode("new"); + } + }, [mode, canEdit]); + + const submitQuery = useCallback( + async (queryPrompt: string, submitMode: AIQueryMode = mode) => { + if (!queryPrompt.trim() || isLoading) return; + const currentQuery = getCurrentQuery?.(); + if (submitMode === "edit" && !currentQuery?.trim()) return; + + setIsLoading(true); + setThinking(""); + setError(null); + setShowThinking(true); + setLastResult(null); + + // Abort any existing request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + + try { + const formData = new FormData(); + formData.append("prompt", queryPrompt); + formData.append("mode", submitMode); + if (submitMode === "edit" && currentQuery) { + formData.append("currentQuery", currentQuery); + } + + const response = await fetch(resourcePath, { + method: "POST", + body: formData, + signal: abortControllerRef.current.signal, + }); + + if (!response.ok) { + const errorData = (await response.json()) as { error?: string }; + setError(errorData.error || "Failed to generate query"); + setIsLoading(false); + setLastResult("error"); + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + setError("No response stream"); + setIsLoading(false); + setLastResult("error"); + return; + } + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete events from buffer + const lines = buffer.split("\n\n"); + buffer = lines.pop() || ""; // Keep incomplete line in buffer + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const event = JSON.parse(line.slice(6)) as StreamEventType; + processStreamEvent(event); + } catch { + // Ignore parse errors + } + } + } + } + + // Process any remaining data + if (buffer.startsWith("data: ")) { + try { + const event = JSON.parse(buffer.slice(6)) as StreamEventType; + processStreamEvent(event); + } catch { + // Ignore parse errors + } + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + // Request was aborted, ignore + return; + } + setError(err instanceof Error ? err.message : "An error occurred"); + setLastResult("error"); + } finally { + setIsLoading(false); + } + }, + [isLoading, resourcePath, mode, getCurrentQuery] + ); + + const processStreamEvent = useCallback( + (event: StreamEventType) => { + switch (event.type) { + case "thinking": + setThinking((prev) => prev + event.content); + break; + case "tool_call": + setThinking((prev) => prev + `\nValidating query...\n`); + break; + case "result": + if (event.success) { + onQueryGenerated(event.query); + setPrompt(""); + setLastResult("success"); + // Keep thinking visible to show what happened + } else { + setError(event.error); + setLastResult("error"); + } + break; + } + }, + [onQueryGenerated] + ); + + const handleSubmit = useCallback( + (e?: React.FormEvent) => { + e?.preventDefault(); + submitQuery(prompt); + }, + [prompt, submitQuery] + ); + + // Auto-submit when autoSubmitPrompt changes + useEffect(() => { + if ( + autoSubmitPrompt && + autoSubmitPrompt.trim() && + autoSubmitPrompt !== lastAutoSubmitRef.current && + !isLoading + ) { + lastAutoSubmitRef.current = autoSubmitPrompt; + setPrompt(autoSubmitPrompt); + submitQuery(autoSubmitPrompt); + } + }, [autoSubmitPrompt, isLoading, submitQuery]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + // Auto-hide error after delay + useEffect(() => { + if (error) { + const timer = setTimeout(() => setError(null), 15000); + return () => clearTimeout(timer); + } + }, [error]); + + return ( +
+ {/* Gradient border wrapper like the schedules AI input */} +
+
+
+