GenUI Input
GenUI-powered input component with automatic text rewriting and tone customization.
Preview
Loading preview...
Installation
npx shadcn@latest add https://gencn-ui.encatch.com/r/genui-input.json"use client"; // This component is a wrapper around the Shadcn UI Input component that adds AI capabilities. import * as React from "react"; import { Sparkles, Wand2, Languages, Repeat2, SpellCheck } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { Spinner } from "@/components/ui/spinner"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from "@/components/ui/select"; import { Command, CommandList, CommandItem, CommandEmpty, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverAnchor, } from "@/components/ui/popover"; import { type DetectedLanguage, type RewriterOptions, } from "@/registry/new-york/gencn-ui/items/shared/genui-types"; import { proofreadOnce } from "@/registry/new-york/gencn-ui/items/shared/lib/proofreader"; import { ensureTranslator } from "@/registry/new-york/gencn-ui/items/shared/lib/translator"; import { isWriterSupported as svcIsWriterSupported, checkWriterAvailability as svcCheckWriterAvailability, ensureWriter as svcEnsureWriter, } from "@/registry/new-york/gencn-ui/items/shared/lib/writer"; import { isRewriterSupported as svcIsRewriterSupported, checkRewriterAvailability as svcCheckRewriterAvailability, ensureRewriter as svcEnsureRewriter, } from "@/registry/new-york/gencn-ui/items/shared/lib/rewriter"; import { detectLanguages } from "@/registry/new-york/gencn-ui/items/shared/lib/language-detector"; export type ButtonVisibility = "ALWAYS" | "ON_FOCUS"; export interface GenUIInputProps extends React.ComponentProps<"input"> { buttonVisibility?: ButtonVisibility; buttonContent?: React.ReactNode; containerClassName?: string; buttonClassName?: string; features?: Array< "compose" | "improve" | "fix-grammar" | "translate" | "auto-suggest" >; translateTargets?: string[]; translateLanguageMap?: Record<string, string>; placeholderPrompt?: string; writerOptions?: { tone?: "formal" | "neutral" | "casual"; format?: "markdown" | "plain-text"; length?: "short" | "medium" | "long"; sharedContext?: string; expectedInputLanguages?: string[]; expectedContextLanguages?: string[]; outputLanguage?: string; }; autoSuggestDebounceMs?: number; autoSuggestMinChars?: number; autoSuggestPrompt?: string; onAccept?: (text: string) => void; onAIError?: (error: Error) => void; } export const GenUIInput = React.forwardRef<HTMLInputElement, GenUIInputProps>( ( { buttonVisibility = "ALWAYS", buttonContent, containerClassName, buttonClassName, className, onFocus, onBlur, features, translateTargets, translateLanguageMap, placeholderPrompt, writerOptions, autoSuggestDebounceMs = 500, autoSuggestMinChars = 3, autoSuggestPrompt, onAccept, onAIError, ...props }, ref ) => { const [isFocused, setIsFocused] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false); const inputRef = React.useRef<HTMLInputElement>(null); // Auto-suggest state const [suggestions, setSuggestions] = React.useState<string[]>([]); const [selectedIndex, setSelectedIndex] = React.useState<number>(-1); const [isLoadingSuggestions, setIsLoadingSuggestions] = React.useState(false); const suggestTimeoutRef = React.useRef<NodeJS.Timeout | null>(null); const suggestAbortRef = React.useRef<AbortController | null>(null); const autoSuggestRewriterRef = React.useRef<any>(null); const containerRef = React.useRef<HTMLDivElement>(null); const autoSuggestActive = features?.includes("auto-suggest") ?? false; // AI Feature UI state const [isFeatureOpen, setIsFeatureOpen] = React.useState(false); const [activeFeature, setActiveFeature] = React.useState< "compose" | "improve" | "fix-grammar" | "translate" | null >(null); const [phase, setPhase] = React.useState< "prompt" | "generating" | "result" >("prompt"); const [promptText, setPromptText] = React.useState(""); const [resultText, setResultText] = React.useState(""); const [aiError, setAiError] = React.useState<string | null>(null); const [downloadProgress, setDownloadProgress] = React.useState< number | null >(null); const [detected, setDetected] = React.useState<DetectedLanguage[] | null>( null ); const [selectedTarget, setSelectedTarget] = React.useState<string>(""); const abortRef = React.useRef<AbortController | null>(null); const writerRef = React.useRef<any>(null); const rewriterRef = React.useRef<any>(null); const isWriterSupported = svcIsWriterSupported(); const isRewriterSupported = svcIsRewriterSupported(); React.useImperativeHandle( ref, () => inputRef.current as HTMLInputElement, [] ); const handleFocus = React.useCallback( (e: React.FocusEvent<HTMLInputElement>) => { setIsFocused(true); onFocus?.(e); }, [onFocus] ); const handleBlur = React.useCallback( (e: React.FocusEvent<HTMLInputElement>) => { setIsFocused(false); // Delay clearing suggestions to allow clicking on them setTimeout(() => { const activeElement = document.activeElement; if ( containerRef.current && !containerRef.current.contains(activeElement) ) { setSuggestions([]); setSelectedIndex(-1); } }, 200); onBlur?.(e); }, [onBlur] ); const ensureWriter = React.useCallback(async () => { try { if (!isWriterSupported) { throw new Error("Chrome Writer API is not supported."); } const avail = await svcCheckWriterAvailability(); if (avail === "unavailable" || avail === null) { throw new Error("Writer API is unavailable on this device."); } const options = { ...(writerOptions ?? {}), monitor: (m: any) => { m?.addEventListener?.("downloadprogress", (e: any) => { if (typeof e.loaded === "number") { setDownloadProgress(Math.round(e.loaded * 100)); } }); }, }; writerRef.current = await svcEnsureWriter(options as any); return writerRef.current; } catch (err) { setAiError((err as Error).message); onAIError?.(err as Error); return null; } }, [isWriterSupported, writerOptions, onAIError]); const startComposeStreaming = React.useCallback(async () => { setAiError(null); setResultText(""); setPhase("generating"); setIsLoading(true); try { const writer = writerRef.current ?? (await ensureWriter()); if (!writer) { setIsLoading(false); setPhase("prompt"); return; } abortRef.current?.abort(); abortRef.current = new AbortController(); const stream = writer.writeStreaming(promptText, { signal: abortRef.current.signal, context: writerOptions?.sharedContext, }); let acc = ""; for await (const chunk of stream) { acc += chunk; setResultText(acc); } setPhase("result"); } catch (err) { if ((err as any)?.name !== "AbortError") { const msg = (err as Error).message; setAiError(msg); onAIError?.(err as Error); setPhase("prompt"); } } finally { setIsLoading(false); } }, [ensureWriter, promptText, writerOptions, onAIError]); const closeFeature = React.useCallback(() => { abortRef.current?.abort(); abortRef.current = null; setIsFeatureOpen(false); setActiveFeature(null); setPhase("prompt"); setPromptText(""); setResultText(""); setAiError(null); setDownloadProgress(null); setDetected(null); }, []); // Improve controls state const [tone, setTone] = React.useState< "more-formal" | "as-is" | "more-casual" >("as-is"); const [lengthPref, setLengthPref] = React.useState< "shorter" | "as-is" | "longer" >("as-is"); const [formatPref, setFormatPref] = React.useState< "as-is" | "markdown" | "plain-text" >("plain-text"); const [contextText, setContextText] = React.useState(""); const ensureRewriter = React.useCallback(async () => { try { if (!isRewriterSupported) { throw new Error("Chrome Rewriter API is not supported."); } const avail = await svcCheckRewriterAvailability(); if (avail === "unavailable" || avail === null) { throw new Error("Rewriter API is unavailable on this device."); } const options: RewriterOptions = { tone, length: lengthPref, format: formatPref, monitor: (m: any) => { m?.addEventListener?.("downloadprogress", (e: any) => { if (typeof e.loaded === "number") { setDownloadProgress(Math.round(e.loaded * 100)); } }); }, }; rewriterRef.current = await svcEnsureRewriter(options as any); return rewriterRef.current; } catch (err) { setAiError((err as Error).message); onAIError?.(err as Error); return null; } }, [isRewriterSupported, tone, lengthPref, formatPref, onAIError]); const startImprove = React.useCallback(async () => { setAiError(null); setResultText(""); setPhase("generating"); setIsLoading(true); try { const text = inputRef.current?.value || ""; if (!text.trim()) { throw new Error("Please enter some text to improve."); } const rewriter = rewriterRef.current ?? (await ensureRewriter()); if (!rewriter) { setIsLoading(false); setPhase("prompt"); return; } abortRef.current?.abort(); abortRef.current = new AbortController(); if (typeof rewriter.rewriteStreaming === "function") { const stream = rewriter.rewriteStreaming(text, { signal: abortRef.current.signal, context: contextText || undefined, }); let acc = ""; for await (const chunk of stream) { acc += chunk; setResultText(acc); } setPhase("result"); } else { const result = await rewriter.rewrite(text, { signal: abortRef.current.signal, context: contextText || undefined, }); setResultText(result); setPhase("result"); } } catch (err) { if ((err as any)?.name === "AbortError") { // ignore aborts } else { const msg = (err as Error).message; setAiError(msg); onAIError?.(err as Error); setPhase("prompt"); } } finally { setIsLoading(false); } }, [ensureRewriter, contextText, onAIError]); React.useEffect(() => { return () => { abortRef.current?.abort(); try { writerRef.current?.destroy?.(); } catch {} }; }, []); const openCompose = React.useCallback(() => { setActiveFeature("compose"); setIsFeatureOpen(true); setPhase("prompt"); setPromptText(""); setResultText(""); setAiError(null); }, []); const openImprove = React.useCallback(() => { setActiveFeature("improve"); setIsFeatureOpen(true); setPhase("prompt"); setResultText(""); setAiError(null); setDownloadProgress(null); }, []); const openFixGrammar = React.useCallback(() => { setActiveFeature("fix-grammar"); setIsFeatureOpen(true); setPhase("generating"); setResultText(""); setAiError(null); setDownloadProgress(null); setDetected(null); }, []); const openTranslate = React.useCallback(async () => { setActiveFeature("translate"); setIsFeatureOpen(true); setPhase("prompt"); setResultText(""); setAiError(null); setDownloadProgress(null); // Detect language when modal opens const text = inputRef.current?.value || ""; let detectedSource: string | null = null; if (text.trim()) { try { const detectedLangs = await detectLanguages(text); setDetected(detectedLangs); if (detectedLangs && detectedLangs.length > 0) { detectedSource = detectedLangs[0].detectedLanguage; } } catch { setDetected(null); } } else { setDetected(null); } // Preselect first provided target that is not the detected source language const availableTargets = translateTargets?.filter((code: string) => { return !detectedSource || code !== detectedSource; }) || []; setSelectedTarget(availableTargets[0] || ""); }, [translateTargets]); const startTranslate = React.useCallback(async () => { setAiError(null); setResultText(""); setPhase("generating"); setIsLoading(true); try { const text = inputRef.current?.value || ""; if (!text.trim()) throw new Error("Please enter some text to translate."); if (!selectedTarget) throw new Error("Please select a target language."); const detectedLangs = await detectLanguages(text); setDetected(detectedLangs); const sourceLanguage = detectedLangs && detectedLangs.length > 0 ? detectedLangs[0].detectedLanguage : "en"; abortRef.current?.abort(); abortRef.current = new AbortController(); const translator = await ensureTranslator({ sourceLanguage, targetLanguage: selectedTarget, monitor(m: any) { try { m.addEventListener("downloadprogress", (e: any) => { if (typeof e.loaded === "number") setDownloadProgress(Math.round(e.loaded * 100)); }); } catch {} }, } as any); if (typeof (translator as any).translateStreaming === "function") { const stream = (translator as any).translateStreaming(text, { signal: abortRef.current.signal, }); let acc = ""; for await (const chunk of stream) { acc += chunk; setResultText(acc); } setPhase("result"); } else { const result = await (translator as any).translate(text); setResultText(result); setPhase("result"); } } catch (err) { if ((err as any)?.name !== "AbortError") { const msg = (err as Error).message; setAiError(msg); setPhase("result"); } } finally { setIsLoading(false); } }, [selectedTarget]); const startFixGrammar = React.useCallback(async () => { setAiError(null); setIsLoading(true); try { const text = inputRef.current?.value || ""; if (!text.trim()) throw new Error("Please enter some text to fix grammar."); const detectedLangs = await detectLanguages(text); setDetected(detectedLangs); const topLanguage = detectedLangs && detectedLangs.length > 0 ? detectedLangs[0].detectedLanguage : "en"; const res = await proofreadOnce(text, { expectedInputLanguages: [topLanguage], onProgress: (p) => setDownloadProgress(p), }); setResultText(res.corrected); setPhase("result"); } catch (err) { const msg = (err as Error).message; setAiError(msg); setPhase("result"); } finally { setIsLoading(false); } }, []); React.useEffect(() => { if ( isFeatureOpen && activeFeature === "fix-grammar" && phase === "generating" && !resultText ) { startFixGrammar(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isFeatureOpen, activeFeature, phase]); const acceptResult = React.useCallback(() => { if (inputRef.current) { inputRef.current.value = resultText; const event = new Event("input", { bubbles: true }); inputRef.current.dispatchEvent(event); } onAccept?.(resultText); closeFeature(); }, [resultText, onAccept, closeFeature]); const regen = React.useCallback(() => { setPhase("prompt"); setResultText(""); setAiError(null); }, []); const handleStop = React.useCallback(() => { abortRef.current?.abort(); setIsLoading(false); setPhase("result"); }, []); // Clear suggestions helper const clearSuggestions = React.useCallback(() => { setSuggestions([]); setSelectedIndex(-1); setIsLoadingSuggestions(false); suggestAbortRef.current?.abort(); suggestAbortRef.current = null; if (suggestTimeoutRef.current) { clearTimeout(suggestTimeoutRef.current); suggestTimeoutRef.current = null; } }, []); // Ensure rewriter for auto-suggest const ensureAutoSuggestRewriter = React.useCallback(async () => { try { if (!isRewriterSupported) { return null; } if (autoSuggestRewriterRef.current) { return autoSuggestRewriterRef.current; } const rewriter = await svcEnsureRewriter({ tone: "as-is", length: "as-is", format: "plain-text", }); autoSuggestRewriterRef.current = rewriter; return rewriter; } catch (err) { console.error("Failed to initialize auto-suggest rewriter:", err); return null; } }, [isRewriterSupported]); // Generate suggestions with uniqueness checking const generateSuggestions = React.useCallback( async (text: string) => { if ( !autoSuggestActive || !text.trim() || text.trim().length < autoSuggestMinChars ) { clearSuggestions(); return; } // Debounce: cancel prior if (suggestTimeoutRef.current) { clearTimeout(suggestTimeoutRef.current); } suggestTimeoutRef.current = setTimeout(async () => { try { setIsLoadingSuggestions(true); suggestAbortRef.current?.abort(); suggestAbortRef.current = new AbortController(); const rewriter = await ensureAutoSuggestRewriter(); if (!rewriter) { clearSuggestions(); return; } // Use provided prompt or default const prompt = autoSuggestPrompt || "Continue from the caret as an autocomplete. Do not change or repeat any existing user text before the caret. If you must return the full text, ensure it begins with the original text exactly and then adds only the natural continuation. No quotes or prefaces. Keep it coherent and concise (about 8–12 words)."; // Start with empty suggestions array setSuggestions([]); setSelectedIndex(-1); // Track completion count let completedCount = 0; const totalSuggestions = 3; // Generate 3 variants in parallel, show them as they arrive [0, 1, 2].forEach(async (index) => { try { let result: string | null = null; if (typeof rewriter.rewrite === "function") { result = (await rewriter.rewrite(text, { context: prompt, signal: suggestAbortRef.current?.signal, })) as string; } else if (typeof rewriter.rewriteStreaming === "function") { let acc = ""; const stream = rewriter.rewriteStreaming(text, { context: prompt, signal: suggestAbortRef.current?.signal, }); for await (const chunk of stream) { acc += chunk; } result = acc; } // Check if request was aborted if (suggestAbortRef.current?.signal.aborted) { return; } // Update suggestions as each one arrives if (result && result.trim().length > 0) { setSuggestions((prev) => { // Append the new suggestion to the array const newSuggestions = [...prev, result!]; // Limit to 3 suggestions max return newSuggestions.slice(0, 3); }); } // Update loading state completedCount++; // Stop loading when all 3 are complete if (completedCount >= totalSuggestions) { setIsLoadingSuggestions(false); } } catch (err) { if ((err as any)?.name === "AbortError") { return; } console.error(`Error generating suggestion ${index + 1}:`, err); // Update completion count even on error completedCount++; // Stop loading when all are complete if (completedCount >= totalSuggestions) { setIsLoadingSuggestions(false); } } }); } catch (err) { if ((err as any)?.name !== "AbortError") { // Silent fail for auto-suggest console.error("Auto-suggest error:", err); } clearSuggestions(); } }, autoSuggestDebounceMs); }, [ autoSuggestActive, autoSuggestMinChars, autoSuggestDebounceMs, autoSuggestPrompt, ensureAutoSuggestRewriter, clearSuggestions, ] ); // Apply selected suggestion const applySuggestion = React.useCallback( (suggestion: string) => { if (inputRef.current) { inputRef.current.value = suggestion; const event = new Event("input", { bubbles: true }); inputRef.current.dispatchEvent(event); } clearSuggestions(); inputRef.current?.focus(); }, [clearSuggestions] ); // Handle input changes const handleInput = React.useCallback( (e: React.FormEvent<HTMLInputElement>) => { const value = e.currentTarget.value; if (autoSuggestActive) { generateSuggestions(value); } props.onInput?.(e); }, [autoSuggestActive, generateSuggestions, props] ); // Handle keyboard navigation const handleKeyDown = React.useCallback( (e: React.KeyboardEvent<HTMLInputElement>) => { if (!autoSuggestActive || suggestions.length === 0) { props.onKeyDown?.(e); return; } if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((prev) => prev < suggestions.length - 1 ? prev + 1 : 0 ); } else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((prev) => prev > 0 ? prev - 1 : suggestions.length - 1 ); } else if ( e.key === "Enter" && selectedIndex >= 0 && selectedIndex < suggestions.length ) { e.preventDefault(); applySuggestion(suggestions[selectedIndex]); } else if (e.key === "Escape") { e.preventDefault(); clearSuggestions(); } else { props.onKeyDown?.(e); } }, [ autoSuggestActive, suggestions, selectedIndex, applySuggestion, clearSuggestions, props, ] ); // Cleanup on unmount React.useEffect(() => { return () => { clearSuggestions(); try { autoSuggestRewriterRef.current?.destroy?.(); } catch {} autoSuggestRewriterRef.current = null; }; }, [clearSuggestions]); const featureIcon = React.useMemo(() => { if (!activeFeature && (features?.length ?? 0) === 1) { const f = features![0]; if (f === "compose") return <Wand2 className="size-4" />; if (f === "translate") return <Languages className="size-4" />; if (f === "improve" || f === "fix-grammar") return <Repeat2 className="size-4" />; } return <Wand2 className="size-4" />; }, [activeFeature, features]); const shouldShowButton = buttonVisibility === "ALWAYS" || (buttonVisibility === "ON_FOCUS" && isFocused); const defaultButtonContent = buttonContent ?? ( <Sparkles className="size-4" /> ); const buttonContentWithLoading = isLoading ? ( <Spinner className="size-4" /> ) : ( defaultButtonContent ); const hasFeatureUI = Array.isArray(features) && features.length > 0; const showSuggestions = autoSuggestActive && (suggestions.length > 0 || isLoadingSuggestions); return ( <div ref={containerRef} className={cn("relative", containerClassName)}> <Popover open={showSuggestions} onOpenChange={(open) => { if (!open) { clearSuggestions(); } }} > <PopoverAnchor asChild> <Input ref={inputRef} className={cn( className, shouldShowButton && hasFeatureUI && "pr-12" )} onFocus={handleFocus} onBlur={handleBlur} onInput={handleInput} onKeyDown={handleKeyDown} {...props} /> </PopoverAnchor> {/* Auto-suggest dropdown */} {showSuggestions && ( <PopoverContent side="bottom" align="start" className="w-[var(--radix-popover-trigger-width)] p-0" onOpenAutoFocus={(e) => e.preventDefault()} > <Command shouldFilter={false} value={ selectedIndex >= 0 && selectedIndex < suggestions.length ? suggestions[selectedIndex] : undefined } > <CommandList className="max-h-none"> {isLoadingSuggestions ? ( <CommandEmpty> <div className="flex items-center justify-center py-4"> <Spinner className="size-4 mr-2" /> <span className="text-sm text-muted-foreground"> Generating suggestions... </span> </div> </CommandEmpty> ) : suggestions.length > 0 ? ( suggestions.map((suggestion, index) => ( <CommandItem key={index} value={suggestion} onSelect={() => applySuggestion(suggestion)} className={cn( "cursor-pointer", selectedIndex === index && "bg-accent" )} data-selected={selectedIndex === index} > <span className="text-sm">{suggestion}</span> </CommandItem> )) ) : null} </CommandList> </Command> </PopoverContent> )} </Popover> {shouldShowButton && hasFeatureUI && (features!.length === 1 ? ( <Button type="button" size="icon" variant="ghost" onClick={() => { if (features![0] === "compose") { openCompose(); } else if (features![0] === "improve") { openImprove(); } else if (features![0] === "fix-grammar") { openFixGrammar(); } else if (features![0] === "translate") { openTranslate(); } }} disabled={isLoading} className={cn( "absolute right-2 top-1/2 -translate-y-1/2 h-7 w-7 rounded-md shadow-sm hover:bg-accent/80 transition-opacity z-10", isLoading && "cursor-wait", buttonClassName )} aria-label="AI actions" > {isLoading ? <Spinner className="size-4" /> : featureIcon} </Button> ) : ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button type="button" size="icon" variant="ghost" disabled={isLoading} className={cn( "absolute right-2 top-1/2 -translate-y-1/2 h-7 w-7 rounded-md shadow-sm hover:bg-accent/80 transition-opacity z-10", isLoading && "cursor-wait", buttonClassName )} aria-label="Choose AI action" > {isLoading ? ( <Spinner className="size-4" /> ) : ( <Wand2 className="size-4" /> )} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="min-w-44"> {features!.includes("compose") && ( <DropdownMenuItem onClick={openCompose}> <Wand2 className="mr-2 h-4 w-4" /> Compose </DropdownMenuItem> )} {features!.includes("improve") && ( <DropdownMenuItem onClick={openImprove}> <Repeat2 className="mr-2 h-4 w-4" /> Improve </DropdownMenuItem> )} {features!.includes("fix-grammar") && ( <DropdownMenuItem onClick={openFixGrammar}> <SpellCheck className="mr-2 h-4 w-4" /> Fix grammar </DropdownMenuItem> )} {features!.includes("translate") && ( <DropdownMenuItem onClick={openTranslate}> <Languages className="mr-2 h-4 w-4" /> Translate </DropdownMenuItem> )} </DropdownMenuContent> </DropdownMenu> ))} {/* Compose Modal */} <Dialog open={isFeatureOpen && activeFeature === "compose"} onOpenChange={(open) => { if (!open) closeFeature(); }} > <DialogContent className="sm:max-w-lg"> <DialogHeader> <DialogTitle>Compose with AI</DialogTitle> </DialogHeader> {aiError && ( <Alert variant="destructive" className="mb-2"> <AlertTitle>Error</AlertTitle> <AlertDescription>{aiError}</AlertDescription> </Alert> )} {downloadProgress !== null && downloadProgress < 100 && ( <div className="text-xs text-muted-foreground"> Model downloading… {downloadProgress}% </div> )} {phase === "prompt" && ( <div className="space-y-3"> <Input placeholder={placeholderPrompt || "describe what you want"} value={promptText} onChange={(e) => setPromptText(e.target.value)} /> </div> )} {phase === "generating" && ( <div className="space-y-3"> <div className="flex items-center gap-2 text-sm text-muted-foreground"> <Spinner className="size-4" /> Generating… </div> {resultText && ( <Textarea value={resultText} readOnly className="min-h-[160px]" /> )} </div> )} {phase === "result" && ( <div className="space-y-3"> <Textarea value={resultText} readOnly className="min-h-[200px]" /> </div> )} <DialogFooter className="gap-2"> {phase === "prompt" && ( <Button onClick={startComposeStreaming} disabled={!promptText.trim() || isLoading} > {isLoading ? <Spinner className="mr-2 size-4" /> : null} Generate </Button> )} {phase === "generating" && ( <Button variant="outline" onClick={handleStop}> Stop </Button> )} {phase === "result" && ( <> <Button variant="outline" onClick={regen}> Regenerate </Button> {typeof resultText === "string" && resultText.trim().length > 0 && ( <Button onClick={acceptResult}>Accept</Button> )} </> )} </DialogFooter> </DialogContent> </Dialog> {/* Fix Grammar Modal */} <Dialog open={isFeatureOpen && activeFeature === "fix-grammar"} onOpenChange={(open: boolean) => { if (!open) closeFeature(); }} > <DialogContent className="sm:max-w-lg"> <DialogHeader> <DialogTitle>Fix grammar</DialogTitle> </DialogHeader> {aiError && ( <Alert variant="destructive" className="mb-2"> <AlertTitle>Error</AlertTitle> <AlertDescription>{aiError}</AlertDescription> </Alert> )} {detected && detected.length > 0 && ( <div className="mb-2 text-xs text-muted-foreground"> Detected: {detected.map((d) => d.detectedLanguage).join(", ")} </div> )} {downloadProgress !== null && downloadProgress < 100 && ( <div className="text-xs text-muted-foreground"> Model downloading… {downloadProgress}% </div> )} {phase === "generating" && ( <div className="space-y-3"> <div className="flex items-center gap-2 text-sm text-muted-foreground"> <Spinner className="size-4" /> Fixing grammar… </div> {resultText && ( <Textarea value={resultText} readOnly className="min-h-[160px]" /> )} </div> )} {phase === "result" && ( <div className="space-y-3"> <Textarea value={resultText} readOnly className="min-h-[200px]" /> </div> )} <DialogFooter className="gap-2"> {phase === "generating" && ( <Button variant="outline" onClick={handleStop}> Stop </Button> )} {phase === "result" && ( <> <Button variant="outline" onClick={() => { setAiError(null); setPhase("generating"); (async () => { try { const text = inputRef.current?.value || ""; const top = detected && detected.length > 0 ? detected[0].detectedLanguage : "en"; const res = await proofreadOnce(text, { expectedInputLanguages: [top], onProgress: (p) => setDownloadProgress(p), }); setResultText(res.corrected); setPhase("result"); } catch (err) { const msg = (err as Error).message; setAiError(msg); setPhase("result"); } })(); }} > Regenerate </Button> {typeof resultText === "string" && resultText.trim().length > 0 && ( <Button onClick={acceptResult}>Accept</Button> )} </> )} </DialogFooter> </DialogContent> </Dialog> {/* Improve Modal */} <Dialog open={isFeatureOpen && activeFeature === "improve"} onOpenChange={(open: boolean) => { if (!open) closeFeature(); }} > <DialogContent className="sm:max-w-lg"> <DialogHeader> <DialogTitle>Improve writing</DialogTitle> </DialogHeader> {aiError && ( <Alert variant="destructive" className="mb-2"> <AlertTitle>Error</AlertTitle> <AlertDescription>{aiError}</AlertDescription> </Alert> )} {downloadProgress !== null && downloadProgress < 100 && ( <div className="text-xs text-muted-foreground"> Model downloading… {downloadProgress}% </div> )} {phase === "prompt" && ( <div className="space-y-3"> <div className="grid grid-cols-3 gap-2"> <Select value={tone} onValueChange={(v) => setTone(v as any)}> <SelectTrigger className="h-8"> <SelectValue placeholder="Tone" /> </SelectTrigger> <SelectContent> <SelectItem value="as-is">Tone: As-is</SelectItem> <SelectItem value="more-formal"> Tone: More formal </SelectItem> <SelectItem value="more-casual"> Tone: More casual </SelectItem> </SelectContent> </Select> <Select value={lengthPref} onValueChange={(v) => setLengthPref(v as any)} > <SelectTrigger className="h-8"> <SelectValue placeholder="Length" /> </SelectTrigger> <SelectContent> <SelectItem value="as-is">Length: As-is</SelectItem> <SelectItem value="shorter">Length: Shorter</SelectItem> <SelectItem value="longer">Length: Longer</SelectItem> </SelectContent> </Select> <Select value={formatPref} onValueChange={(v) => setFormatPref(v as any)} > <SelectTrigger className="h-8"> <SelectValue placeholder="Format" /> </SelectTrigger> <SelectContent> <SelectItem value="plain-text"> Format: Plain text </SelectItem> <SelectItem value="markdown">Format: Markdown</SelectItem> <SelectItem value="as-is">Format: As-is</SelectItem> </SelectContent> </Select> </div> <Input placeholder="Optional context (e.g., audience or constraints)" value={contextText} onChange={(e) => setContextText(e.target.value)} /> </div> )} {phase === "generating" && ( <div className="space-y-3"> <div className="flex items-center gap-2 text-sm text-muted-foreground"> <Spinner className="size-4" /> Improving… </div> {resultText && ( <Textarea value={resultText} readOnly className="min-h-[160px]" /> )} </div> )} {phase === "result" && ( <div className="space-y-3"> <Textarea value={resultText} readOnly className="min-h-[200px]" /> </div> )} <DialogFooter className="gap-2"> {phase === "prompt" && ( <Button onClick={startImprove} disabled={isLoading}> {isLoading ? <Spinner className="mr-2 size-4" /> : null} Generate </Button> )} {phase === "generating" && ( <Button variant="outline" onClick={handleStop}> Stop </Button> )} {phase === "result" && ( <> <Button variant="outline" onClick={regen}> Regenerate </Button> {typeof resultText === "string" && resultText.trim().length > 0 && ( <Button onClick={acceptResult}>Accept</Button> )} </> )} </DialogFooter> </DialogContent> </Dialog> {/* Translate Modal */} <Dialog open={isFeatureOpen && activeFeature === "translate"} onOpenChange={(open) => { if (!open) closeFeature(); }} > <DialogContent className="sm:max-w-lg"> <DialogHeader> <DialogTitle>Translate</DialogTitle> </DialogHeader> {aiError && ( <Alert variant="destructive" className="mb-2"> <AlertTitle>Error</AlertTitle> <AlertDescription>{aiError}</AlertDescription> </Alert> )} {detected && detected.length > 0 && ( <div className="mb-2 text-xs text-muted-foreground"> Detected source: {detected[0].detectedLanguage} </div> )} {downloadProgress !== null && downloadProgress < 100 && ( <div className="text-xs text-muted-foreground"> Model downloading… {downloadProgress}% </div> )} {phase === "prompt" && ( <div className="space-y-3"> <div className="grid grid-cols-1 gap-2"> <Select value={selectedTarget} onValueChange={(v) => setSelectedTarget(v)} > <SelectTrigger className="h-8"> <SelectValue placeholder="Select target language" /> </SelectTrigger> <SelectContent> {translateTargets ?.filter((code: string) => { // Exclude detected source language from target options if (detected && detected.length > 0) { return code !== detected[0].detectedLanguage; } return true; }) .map((code: string) => ( <SelectItem key={code} value={code}> {translateLanguageMap?.[code] || code} </SelectItem> ))} </SelectContent> </Select> </div> </div> )} {phase === "generating" && ( <div className="space-y-3"> <div className="flex items-center gap-2 text-sm text-muted-foreground"> <Spinner className="size-4" /> Translating… </div> {resultText && ( <Textarea value={resultText} readOnly className="min-h-[160px]" /> )} </div> )} {phase === "result" && ( <div className="space-y-3"> <Textarea value={resultText} readOnly className="min-h-[200px]" /> </div> )} <DialogFooter className="gap-2"> {phase === "prompt" && ( <Button onClick={startTranslate} disabled={!selectedTarget || isLoading} > {isLoading ? <Spinner className="mr-2 size-4" /> : null} Translate </Button> )} {phase === "generating" && ( <Button variant="outline" onClick={handleStop}> Stop </Button> )} {phase === "result" && ( <> <Button variant="outline" onClick={() => { setPhase("prompt"); setResultText(""); setAiError(null); }} > Regenerate </Button> {typeof resultText === "string" && resultText.trim().length > 0 && ( <Button onClick={acceptResult}>Accept</Button> )} </> )} </DialogFooter> </DialogContent> </Dialog> </div> ); } ); GenUIInput.displayName = "GenUIInput"; // Local state for Improve controls function useImproveControls() { const [tone, setTone] = React.useState< "more-formal" | "as-is" | "more-casual" >("as-is"); const [lengthPref, setLengthPref] = React.useState< "shorter" | "as-is" | "longer" >("as-is"); const [formatPref, setFormatPref] = React.useState< "as-is" | "markdown" | "plain-text" >("plain-text"); const [contextText, setContextText] = React.useState(""); return { tone, setTone, lengthPref, setLengthPref, formatPref, setFormatPref, contextText, setContextText, }; } function startImproveFactory( rewriterRef: React.MutableRefObject<any>, ensureRewriter: () => Promise<any | null>, setIsLoading: (b: boolean) => void, setAiError: (s: string | null) => void, setResultText: (s: string) => void, setPhase: (p: "prompt" | "generating" | "result") => void, inputRef: React.RefObject<HTMLInputElement>, contextText: string, onAIError?: (e: Error) => void ) { return async function startImprove() { setAiError(null); setResultText(""); setPhase("generating"); setIsLoading(true); try { const text = inputRef.current?.value || ""; if (!text.trim()) throw new Error("Please enter some text to improve."); const rewriter = rewriterRef.current ?? (await ensureRewriter()); if (!rewriter) { setIsLoading(false); setPhase("prompt"); return; } let result = ""; if (typeof rewriter.rewrite === "function") { result = await rewriter.rewrite(text, { context: contextText || undefined, }); } else if (typeof rewriter.rewriteStreaming === "function") { let acc = ""; for await (const chunk of rewriter.rewriteStreaming(text, { context: contextText || undefined, })) { acc += chunk; } result = acc; } setResultText(result); setPhase("result"); } catch (err) { const msg = (err as Error).message; setAiError(msg); onAIError?.(err as Error); setPhase("prompt"); } finally { setIsLoading(false); } }; }
Props
Prop
Type
Dependencies
- react
- genui-provider
- input
- textarea
- button
- spinner
- dropdown-menu
- dialog
- alert
- select
- command
- popover