AI Sonner
AI-powered toast notifications using Sonner with automatic text rewriting and tone customization.
Preview
Loading preview...
Installation
npx shadcn@latest add https://gencn-ui.encatch.com/r/genui-sonner.json"use client"; import * as React from "react"; import { toast, type ExternalToast } from "sonner"; import type { RewriterOptions, RewriteOptions, } from "@/registry/new-york/gencn-ui/items/shared/genui-types"; export type RewriterAvailability = | "available" | "downloadable" | "unavailable" | null; export function isRewriterSupported(): boolean { return "Rewriter" in self; } export async function checkRewriterAvailability(): Promise<RewriterAvailability> { if (!isRewriterSupported()) return null; try { const status = await (self as any).Rewriter.availability(); return status as RewriterAvailability; } catch { return null; } } export async function ensureRewriter( options?: RewriterOptions & { onProgress?: (percent: number) => void } ): Promise<any> { if (!isRewriterSupported()) { throw new Error("Chrome Rewriter API is not supported."); } const availability = await checkRewriterAvailability(); if (availability === "unavailable" || availability === null) { throw new Error("Rewriter API is unavailable on this device."); } const createOptions: any = { ...options, monitor: (monitor: any) => { if (options?.monitor) { options.monitor(monitor); } try { monitor?.addEventListener?.("downloadprogress", (e: any) => { if (typeof e.loaded === "number") { options?.onProgress?.(Math.round(e.loaded * 100)); } }); } catch {} }, }; const rewriter = await (self as any).Rewriter.create(createOptions); return rewriter; } export async function rewriteOnce( text: string, options?: (RewriterOptions & { onProgress?: (percent: number) => void }) & RewriteOptions ): Promise<string> { if (!text.trim()) { throw new Error("Please enter some text to improve."); } const rewriter = await ensureRewriter(options); const result = await rewriter.rewrite(text, { context: options?.context, signal: options?.signal, }); return result as string; } export async function* rewriteStreaming( text: string, options?: (RewriterOptions & { signal?: AbortSignal; onProgress?: (percent: number) => void; }) & RewriteOptions ): AsyncGenerator<string> { if (!text.trim()) { throw new Error("Please enter some text to improve."); } const rewriter = await ensureRewriter(options); const stream = rewriter.rewriteStreaming(text, { context: options?.context, signal: options?.signal, }); let acc = ""; for await (const chunk of stream) { acc += chunk; yield acc as string; } } export type AITone = | "friendly" | "funny" | "sarcastic" | "rude" | "dramatic" | "professional" | "cheerful" | "empathetic" | "concise" | "motivational"; export const GenUISONNER_TONES: AITone[] = [ "friendly", "funny", "sarcastic", "rude", "dramatic", "professional", "cheerful", "empathetic", "concise", "motivational", ]; export type GenUISONNEROptions = ExternalToast & { tone?: AITone; maxLength?: number; retryOnFail?: boolean; }; async function rewriteIfString( input: React.ReactNode, tone?: AITone, maxLength?: number, retryOnFail: boolean = true ): Promise<React.ReactNode> { if (typeof input !== "string") return input; const text = input as string; const toneInstruction = tone ? `Rephrase the following text in a ${tone} tone while preserving intent.` : "Rephrase the following text to improve clarity while preserving intent."; const lengthInstruction = typeof maxLength === "number" && maxLength > 0 ? ` Keep it under ${maxLength} characters.` : ""; const context = `${toneInstruction}${lengthInstruction} Respond with only the rewritten text, no quotes.`; try { const rewritten = await rewriteOnce(text, { context }); return rewritten || text; } catch (err) { if (retryOnFail) return text; throw err; } } function isExternalToastOptions( opts?: ExternalToast | GenUISONNEROptions ): opts is GenUISONNEROptions { return !!opts; } export async function genUIToast( message: React.ReactNode, options?: GenUISONNEROptions ) { const rewritten = await rewriteIfString( message, options?.tone, options?.maxLength, options?.retryOnFail !== false ); return toast(rewritten, options); } genUIToast.success = async function ( message: React.ReactNode, options?: GenUISONNEROptions ) { const rewritten = await rewriteIfString( message, options?.tone, options?.maxLength, options?.retryOnFail !== false ); return toast.success(rewritten, options); }; genUIToast.error = async function ( message: React.ReactNode, options?: GenUISONNEROptions ) { const rewritten = await rewriteIfString( message, options?.tone, options?.maxLength, options?.retryOnFail !== false ); return toast.error(rewritten, options); }; genUIToast.info = async function ( message: React.ReactNode, options?: GenUISONNEROptions ) { const rewritten = await rewriteIfString( message, options?.tone, options?.maxLength, options?.retryOnFail !== false ); return toast.info(rewritten, options); }; genUIToast.warning = async function ( message: React.ReactNode, options?: GenUISONNEROptions ) { const rewritten = await rewriteIfString( message, options?.tone, options?.maxLength, options?.retryOnFail !== false ); return toast.warning(rewritten, options); }; genUIToast.message = async function ( message: React.ReactNode, options?: GenUISONNEROptions ) { const rewritten = await rewriteIfString( message, options?.tone, options?.maxLength, options?.retryOnFail !== false ); return toast.message(rewritten as any, options as any); }; genUIToast.loading = function ( message: React.ReactNode, options?: GenUISONNEROptions ) { // For loading, show immediately; do not wait for rewriting to avoid UX delay. return toast.loading(message, options); }; genUIToast.custom = function ( renderer: Parameters<typeof toast.custom>[0], options?: ExternalToast ) { return toast.custom(renderer, options); }; genUIToast.dismiss = function (toastId?: number | string) { return toast.dismiss(toastId as any); }; genUIToast.promise = async function <T>( promise: Promise<T>, cfg: { loading: React.ReactNode; success: React.ReactNode | ((value: T) => React.ReactNode); error: React.ReactNode | ((error: any) => React.ReactNode); tone?: AITone; maxLength?: number; retryOnFail?: boolean; }, options?: GenUISONNEROptions ) { // Show loading immediately (without waiting for rewrite) const id = toast.loading(cfg.loading, options); try { const value = await promise; const successContent = typeof cfg.success === "function" ? cfg.success(value) : cfg.success; const rewritten = await rewriteIfString( successContent, cfg.tone ?? options?.tone, cfg.maxLength ?? options?.maxLength, (cfg.retryOnFail ?? options?.retryOnFail) !== false ); return toast.success(rewritten, { ...options, id }); } catch (error) { const errorContent = typeof cfg.error === "function" ? cfg.error(error) : cfg.error; const rewritten = await rewriteIfString( errorContent, cfg.tone ?? options?.tone, cfg.maxLength ?? options?.maxLength, (cfg.retryOnFail ?? options?.retryOnFail) !== false ); return toast.error(rewritten, { ...options, id }); } }; // Re-export Toaster passthrough for convenience if consumers need to render it. export { Toaster } from "sonner"; export type GenUIToastType = typeof genUIToast;
Options
Prop
Type
Dependencies
- sonner
- genui-provider