Implement WebGPU detection and integrate Kokoro TTS worker; update TypeScript config and add accordion component

This commit is contained in:
Jack Merrill 2025-04-08 13:22:04 -04:00
parent 5c303b594b
commit 73a0fba45e
Signed by: jack
GPG Key ID: F6BFCA1B80EA6AF7
11 changed files with 741 additions and 373 deletions

View File

@ -1,117 +1,126 @@
import { AppSidebar } from "@/components/app-sidebar"; import { AppSidebar } from "@/components/app-sidebar";
import { NavActions } from "@/components/nav-actions"; import KokoroReader from "@/components/KokoroReader";
import { import { NavActions } from "@/components/nav-actions";
Breadcrumb, import {
BreadcrumbItem, Breadcrumb,
BreadcrumbList, BreadcrumbItem,
BreadcrumbPage, BreadcrumbList,
} from "@/components/ui/breadcrumb"; BreadcrumbPage,
import { Separator } from "@/components/ui/separator"; } from "@/components/ui/breadcrumb";
import { import { Button } from "@/components/ui/button";
SidebarInset, import {
SidebarProvider, Popover,
SidebarTrigger, PopoverContent,
} from "@/components/ui/sidebar"; PopoverTrigger,
import { createClient } from "@/utils/supabase/server"; } from "@/components/ui/popover";
import { redirect } from "next/navigation"; import { Separator } from "@/components/ui/separator";
import { remark } from "remark"; import {
import remarkHtml from "remark-html"; SidebarInset,
SidebarProvider,
export default async function DocumentPage({ SidebarTrigger,
params, } from "@/components/ui/sidebar";
}: { import { createClient } from "@/utils/supabase/server";
params: { id: string }; import { Speech } from "lucide-react";
}) { import { redirect } from "next/navigation";
const supabase = await createClient(); import { remark } from "remark";
import remarkHtml from "remark-html";
const {
data: { user }, export default async function DocumentPage({
} = await supabase.auth.getUser(); params,
}: {
if (!user) { params: { id: string };
return redirect("/login"); }) {
} const supabase = await createClient();
// Fetch the document details based on the ID from params const {
const { data: document, error } = await supabase data: { user },
.from("documents") } = await supabase.auth.getUser();
.select("*")
.eq("id", params.id) if (!user) {
.single(); return redirect("/login");
}
if (error || !document) {
console.error("Error fetching document:", error); // Fetch the document details based on the ID from params
} const { data: document, error } = await supabase
.from("documents")
// If the document doesn't exist, redirect to the documents page or handle it accordingly .select("*")
if (!document) { .eq("id", params.id)
return redirect("/dashboard"); .single();
}
const { data: documents, error: documentsError } = await supabase if (error || !document) {
.from("documents") console.error("Error fetching document:", error);
.select("id, file_name, created_at, owner") }
.eq("owner", user.id)
.order("created_at", { ascending: false }); // If the document doesn't exist, redirect to the documents page or handle it accordingly
if (!document) {
if (documentsError) { return redirect("/dashboard");
console.error("Error fetching documents:", error); }
return <div>Error loading documents.</div>; const { data: documents, error: documentsError } = await supabase
} .from("documents")
.select("id, file_name, created_at, owner")
const pages = (document.ocr_data as any).pages.map( .eq("owner", user.id)
(page: any) => page.markdown .order("created_at", { ascending: false });
);
if (documentsError) {
const processedContent = await remark() console.error("Error fetching documents:", error);
.use(remarkHtml) return <div>Error loading documents.</div>;
.process(pages.join(" ")); }
return ( const pages = (document.ocr_data as any).pages.map(
<SidebarProvider> (page: any) => page.markdown
<AppSidebar );
documents={documents.map((d) => {
return { const processedContent = await remark()
name: d.file_name, .use(remarkHtml)
url: `/dashboard/documents/${d.id}`, .process(pages.join(" "));
emoji: "📄",
}; return (
})} <SidebarProvider>
/> <AppSidebar
<SidebarInset> documents={documents.map((d) => {
<header className="flex h-14 shrink-0 items-center gap-2"> return {
<div className="flex flex-1 items-center gap-2 px-3"> name: d.file_name,
<SidebarTrigger /> url: `/dashboard/documents/${d.id}`,
<Separator emoji: "📄",
orientation="vertical" };
className="mr-2 data-[orientation=vertical]:h-4" })}
/> />
<Breadcrumb> <SidebarInset>
<BreadcrumbList> <header className="flex h-14 shrink-0 items-center gap-2">
<BreadcrumbItem> <div className="flex flex-1 items-center gap-2 px-3">
<BreadcrumbPage className="line-clamp-1"> <SidebarTrigger />
{document.file_name || "Document Details"} <Separator
</BreadcrumbPage> orientation="vertical"
</BreadcrumbItem> className="mr-2 data-[orientation=vertical]:h-4"
</BreadcrumbList> />
</Breadcrumb> <Breadcrumb>
</div> <BreadcrumbList>
<div className="ml-auto px-3"> <BreadcrumbItem>
<NavActions /> <BreadcrumbPage className="line-clamp-1">
</div> {document.file_name || "Document Details"}
</header> </BreadcrumbPage>
<div </BreadcrumbItem>
className="prose mx-auto px-4 py-10 </BreadcrumbList>
text-white </Breadcrumb>
prose-h1:font-semibold prose-h1:text-2xl prose-h1:mb-4 prose-h1:text-white </div>
prose-h2:font-medium prose-h2:text-xl prose-h2:mb-3 prose-h2:text-white <div className="ml-auto px-3">
prose-a:text-blue-400 hover:prose-a:underline <NavActions pages={pages} />
prose-p:leading-7 prose-p:text-gray-200 </div>
prose-blockquote:italic prose-blockquote:border-l-4 prose-blockquote:pl-4 prose-blockquote:border-gray-600 prose-blockquote:text-gray-300 </header>
prose-code:bg-gray-800 prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:text-gray-200 <div
prose-img:rounded-lg prose-img:shadow-sm" className="prose mx-auto px-4 py-10
dangerouslySetInnerHTML={{ __html: String(processedContent) }} text-white
></div> prose-h1:font-semibold prose-h1:text-2xl prose-h1:mb-4 prose-h1:text-white
</SidebarInset> prose-h2:font-medium prose-h2:text-xl prose-h2:mb-3 prose-h2:text-white
</SidebarProvider> prose-h4:font-medium prose-h4:text-lg prose-h4:mb-2 prose-h4:text-gray-300
); prose-a:text-blue-400 hover:prose-a:underline
} prose-p:leading-7 prose-p:text-gray-200
prose-blockquote:italic prose-blockquote:border-l-4 prose-blockquote:pl-4 prose-blockquote:border-gray-600 prose-blockquote:text-gray-300
prose-code:bg-gray-800 prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:text-gray-200
prose-img:rounded-lg prose-img:shadow-sm"
dangerouslySetInnerHTML={{ __html: String(processedContent) }}
></div>
</SidebarInset>
</SidebarProvider>
);
}

View File

@ -1,61 +1,85 @@
import { AppSidebar } from "@/components/app-sidebar"; import { AppSidebar } from "@/components/app-sidebar";
import { NavActions } from "@/components/nav-actions"; import { NavActions } from "@/components/nav-actions";
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
BreadcrumbList, BreadcrumbList,
BreadcrumbPage, BreadcrumbPage,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import {
SidebarInset, SidebarInset,
SidebarProvider, SidebarProvider,
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { createClient } from "@/utils/supabase/server"; import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function Page() { export default async function Page() {
const supabase = await createClient(); const supabase = await createClient();
const { const {
data: { user }, data: { user },
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
if (!user) { if (!user) {
return redirect("/login"); return redirect("/login");
} }
return ( const { data: documents, error } = await supabase
<SidebarProvider> .from("documents")
<AppSidebar /> .select("*")
<SidebarInset> .eq("owner", user.id)
<header className="flex h-14 shrink-0 items-center gap-2"> .order("created_at", { ascending: false });
<div className="flex flex-1 items-center gap-2 px-3">
<SidebarTrigger /> if (error) {
<Separator console.error("Failed to fetch documents:", error);
orientation="vertical" // Optionally handle the error, e.g., show a message to the user
className="mr-2 data-[orientation=vertical]:h-4" return (
/> <div className="p-4">
<Breadcrumb> <p className="text-red-600">Failed to load documents.</p>
<BreadcrumbList> </div>
<BreadcrumbItem> );
<BreadcrumbPage className="line-clamp-1"> }
Project Management & Task Tracking
</BreadcrumbPage> return (
</BreadcrumbItem> <SidebarProvider>
</BreadcrumbList> <AppSidebar
</Breadcrumb> documents={documents.map((d) => {
</div> return {
<div className="ml-auto px-3"> name: d.file_name,
<NavActions /> url: `/dashboard/documents/${d.id}`,
</div> emoji: "📄",
</header> };
<div className="flex flex-1 flex-col gap-4 px-4 py-10"> })}
<div className="bg-muted/50 mx-auto h-24 w-full max-w-3xl rounded-xl" /> />
<div className="bg-muted/50 mx-auto h-full w-full max-w-3xl rounded-xl" /> <SidebarInset>
</div> <header className="flex h-14 shrink-0 items-center gap-2">
</SidebarInset> <div className="flex flex-1 items-center gap-2 px-3">
</SidebarProvider> <SidebarTrigger />
); <Separator
} orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage className="line-clamp-1 text-muted-foreground">
Select a document...
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="ml-auto px-3">
<NavActions />
</div>
</header>
<div className="flex flex-1 flex-col gap-4 px-4 py-10">
<div className="bg-muted/50 mx-auto h-24 w-full max-w-3xl rounded-xl" />
<div className="bg-muted/50 mx-auto h-full w-full max-w-3xl rounded-xl" />
</div>
</SidebarInset>
</SidebarProvider>
);
}

BIN
bun.lockb

Binary file not shown.

175
components/KokoroReader.tsx Normal file
View File

@ -0,0 +1,175 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { Button } from "./ui/button";
import { Play } from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./ui/accordion";
import { Label } from "./ui/label";
export default function KokoroReader({ pages }: { pages: any[] }) {
// Create a reference to the worker object.
const worker = useRef<Worker>(null);
const [inputText, setInputText] = useState(
"Life is like a box of chocolates. You never know what you're gonna get."
);
const [selectedSpeaker, setSelectedSpeaker] = useState("af_heart");
const [voices, setVoices] = useState<any[]>([]);
const [status, setStatus] = useState<"ready" | "running" | null>(null);
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState("Loading...");
const [results, setResults] = useState<{ text: string; src: string }[]>([]);
// We use the `useEffect` hook to setup the worker as soon as the `App` component is mounted.
useEffect(() => {
// Create the worker if it does not yet exist.
console.log("Initializing worker...");
worker.current ??= new Worker("/workers/kokoro-worker.js", {
type: "module",
});
console.log("Worker initialized");
// Create a callback function for messages from the worker thread.
const onMessageReceived = (e: any) => {
switch (e.data.status) {
case "device":
setLoadingMessage(`Loading model (device="${e.data.device}")`);
break;
case "ready":
setStatus("ready");
setVoices(e.data.voices);
break;
case "error":
setError(e.data.data);
break;
case "complete":
const { audio, text } = e.data;
// Generation complete: re-enable the "Generate" button
setResults((prev) => [{ text, src: audio }, ...prev]);
setStatus("ready");
break;
}
};
console.log("onmessagereceived");
const onErrorReceived = (e: any) => {
console.error("Worker error:", e);
setError(e.message);
};
console.log("Attaching event listeners to worker");
// Attach the callback function as an event listener.
worker.current.addEventListener("message", onMessageReceived);
worker.current.addEventListener("error", onErrorReceived);
console.log(worker.current);
// Define a cleanup function for when the component is unmounted.
return () => {
worker.current!.removeEventListener("message", onMessageReceived);
worker.current!.removeEventListener("error", onErrorReceived);
};
}, []);
const handleSubmit = (e: any) => {
e.preventDefault();
setStatus("running");
worker.current!.postMessage({
type: "generate",
text: inputText.trim(),
voice: selectedSpeaker,
});
};
return (
<div className="flex flex-col items-center justify-center pt-4 relative overflow-hidden font-sans">
<div className="max-w-3xl w-full relative z-[2]">
<div className="items-center justify-center text-center">
<Button variant="ghost" size="icon" className="h-10 w-10">
<Play />
</Button>
</div>
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger className="text-white pb-2">
Settings
</AccordionTrigger>
<AccordionContent className="pb-2">
<Label>Voice</Label>
<select
value={selectedSpeaker}
onChange={(e) => setSelectedSpeaker(e.target.value)}
className="w-full bg-gray-700/50 backdrop-blur-sm border-2 border-gray-600 rounded-md text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{Object.entries(voices).map(([id, voice]) => (
<option key={id} value={id}>
{voice.name} (
{voice.language === "en-us" ? "American" : "British"}{" "}
{voice.gender})
</option>
))}
</select>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
{/* <div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<textarea
placeholder="Enter text..."
value={inputText}
onChange={(e) => setInputText(e.target.value)}
className="w-full min-h-[100px] max-h-[300px] bg-gray-700/50 backdrop-blur-sm border-2 border-gray-600 rounded-xl resize-y text-gray-100 placeholder-gray-400 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={Math.min(8, inputText.split("\n").length)}
/>
<div className="flex flex-col items-center space-y-4">
<select
value={selectedSpeaker}
onChange={(e) => setSelectedSpeaker(e.target.value)}
className="w-full bg-gray-700/50 backdrop-blur-sm border-2 border-gray-600 rounded-xl text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{Object.entries(voices).map(([id, voice]) => (
<option key={id} value={id}>
{voice.name} (
{voice.language === "en-us" ? "American" : "British"}{" "}
{voice.gender})
</option>
))}
</select>
<button
type="submit"
className="inline-flex justify-center items-center px-6 py-2 text-lg font-semibold bg-gradient-to-t from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 transition-colors duration-300 rounded-xl text-white disabled:opacity-50"
disabled={status === "running" || inputText.trim() === ""}
>
{status === "running" ? "Generating..." : "Generate"}
</button>
</div>
</form>
</div> */}
{results.map((result, i) => (
<div key={i}>
<div className="text-white bg-gray-800/70 backdrop-blur-sm border border-gray-700 rounded-lg p-4 z-10">
<span className="absolute right-5 font-bold">
#{results.length - i}
</span>
<p className="mb-3 max-w-[95%]">{result.text}</p>
<audio controls src={result.src} className="w-full">
Your browser does not support the audio element.
</audio>
</div>
</div>
))}
</div>
);
}

View File

@ -1,153 +1,165 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
Bell, Bell,
Copy, Copy,
CornerUpLeft, CornerUpLeft,
CornerUpRight, CornerUpRight,
FileText, FileText,
GalleryVerticalEnd, GalleryVerticalEnd,
LineChart, LineChart,
Link, Link,
MoreHorizontal, MoreHorizontal,
Settings2, Settings2,
Star, Speech,
Trash, Star,
Trash2, Trash,
} from "lucide-react" Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button"
import { import { Button } from "@/components/ui/button";
Popover, import {
PopoverContent, Popover,
PopoverTrigger, PopoverContent,
} from "@/components/ui/popover" PopoverTrigger,
import { } from "@/components/ui/popover";
Sidebar, import {
SidebarContent, Sidebar,
SidebarGroup, SidebarContent,
SidebarGroupContent, SidebarGroup,
SidebarMenu, SidebarGroupContent,
SidebarMenuButton, SidebarMenu,
SidebarMenuItem, SidebarMenuButton,
} from "@/components/ui/sidebar" SidebarMenuItem,
} from "@/components/ui/sidebar";
const data = [ import KokoroReader from "./KokoroReader";
[
{ const data = [
label: "Customize Page", [
icon: Settings2, {
}, label: "Customize Page",
{ icon: Settings2,
label: "Turn into wiki", },
icon: FileText, {
}, label: "Turn into wiki",
], icon: FileText,
[ },
{ ],
label: "Copy Link", [
icon: Link, {
}, label: "Copy Link",
{ icon: Link,
label: "Duplicate", },
icon: Copy, {
}, label: "Duplicate",
{ icon: Copy,
label: "Move to", },
icon: CornerUpRight, {
}, label: "Move to",
{ icon: CornerUpRight,
label: "Move to Trash", },
icon: Trash2, {
}, label: "Move to Trash",
], icon: Trash2,
[ },
{ ],
label: "Undo", [
icon: CornerUpLeft, {
}, label: "Undo",
{ icon: CornerUpLeft,
label: "View analytics", },
icon: LineChart, {
}, label: "View analytics",
{ icon: LineChart,
label: "Version History", },
icon: GalleryVerticalEnd, {
}, label: "Version History",
{ icon: GalleryVerticalEnd,
label: "Show delete pages", },
icon: Trash, {
}, label: "Show delete pages",
{ icon: Trash,
label: "Notifications", },
icon: Bell, {
}, label: "Notifications",
], icon: Bell,
[ },
{ ],
label: "Import", [
icon: ArrowUp, {
}, label: "Import",
{ icon: ArrowUp,
label: "Export", },
icon: ArrowDown, {
}, label: "Export",
], icon: ArrowDown,
] },
],
export function NavActions() { ];
const [isOpen, setIsOpen] = React.useState(false)
export function NavActions({ pages }: { pages: any[] }) {
React.useEffect(() => { const [isOpen, setIsOpen] = React.useState(false);
setIsOpen(true)
}, []) React.useEffect(() => {
setIsOpen(true);
return ( }, []);
<div className="flex items-center gap-2 text-sm">
<div className="text-muted-foreground hidden font-medium md:inline-block"> return (
Edit Oct 08 <div className="flex items-center gap-2 text-sm">
</div> {/* <div className="text-muted-foreground hidden font-medium md:inline-block">
<Button variant="ghost" size="icon" className="h-7 w-7"> Edit Oct 08
<Star /> </div> */}
</Button> <Popover>
<Popover open={isOpen} onOpenChange={setIsOpen}> <PopoverTrigger asChild>
<PopoverTrigger asChild> <Button variant="ghost" size="icon" className="h-7 w-7">
<Button <Speech />
variant="ghost" </Button>
size="icon" </PopoverTrigger>
className="data-[state=open]:bg-accent h-7 w-7" <PopoverContent>
> <KokoroReader pages={pages} />
<MoreHorizontal /> </PopoverContent>
</Button> </Popover>
</PopoverTrigger> <Button variant="ghost" size="icon" className="h-7 w-7">
<PopoverContent <Star />
className="w-56 overflow-hidden rounded-lg p-0" </Button>
align="end" <Popover open={isOpen} onOpenChange={setIsOpen}>
> <PopoverTrigger asChild>
<Sidebar collapsible="none" className="bg-transparent"> <Button
<SidebarContent> variant="ghost"
{data.map((group, index) => ( size="icon"
<SidebarGroup key={index} className="border-b last:border-none"> className="data-[state=open]:bg-accent h-7 w-7"
<SidebarGroupContent className="gap-0"> >
<SidebarMenu> <MoreHorizontal />
{group.map((item, index) => ( </Button>
<SidebarMenuItem key={index}> </PopoverTrigger>
<SidebarMenuButton> <PopoverContent
<item.icon /> <span>{item.label}</span> className="w-56 overflow-hidden rounded-lg p-0"
</SidebarMenuButton> align="end"
</SidebarMenuItem> >
))} <Sidebar collapsible="none" className="bg-transparent">
</SidebarMenu> <SidebarContent>
</SidebarGroupContent> {data.map((group, index) => (
</SidebarGroup> <SidebarGroup key={index} className="border-b last:border-none">
))} <SidebarGroupContent className="gap-0">
</SidebarContent> <SidebarMenu>
</Sidebar> {group.map((item, index) => (
</PopoverContent> <SidebarMenuItem key={index}>
</Popover> <SidebarMenuButton>
</div> <item.icon /> <span>{item.label}</span>
) </SidebarMenuButton>
} </SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
</Sidebar>
</PopoverContent>
</Popover>
</div>
);
}

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -1,6 +1,15 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export async function detectWebGPU() {
try {
const adapter = await navigator.gpu.requestAdapter();
return !!adapter;
} catch (e) {
return false;
}
}

View File

@ -1,7 +1,21 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
}; webpack: (config, { isServer }) => {
if (!isServer) {
export default nextConfig; config.module.rules.push({
test: /kokoro-worker\.js$/,
use: { loader: "worker-loader" },
});
}
config.module.rules.push({
test: /\.js$/,
loader: "@open-wc/webpack-import-meta-loader",
});
return config;
},
};
export default nextConfig;

View File

@ -9,6 +9,7 @@
"dependencies": { "dependencies": {
"@ai-sdk/mistral": "^1.2.3", "@ai-sdk/mistral": "^1.2.3",
"@mistralai/mistralai": "^1.5.2", "@mistralai/mistralai": "^1.5.2",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
@ -26,6 +27,7 @@
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"kokoro-js": "^1.2.0",
"lucide-react": "^0.486.0", "lucide-react": "^0.486.0",
"next": "latest", "next": "latest",
"next-themes": "^0.4.3", "next-themes": "^0.4.3",
@ -38,6 +40,7 @@
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@open-wc/webpack-import-meta-loader": "^0.4.7",
"@types/node": "22.10.2", "@types/node": "22.10.2",
"@types/react": "^19.0.2", "@types/react": "^19.0.2",
"@types/react-dom": "19.0.2", "@types/react-dom": "19.0.2",
@ -46,6 +49,7 @@
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwindcss": "^4.1.0", "tailwindcss": "^4.1.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "5.7.2" "typescript": "5.7.2",
"worker-loader": "^3.0.8"
} }
} }

View File

@ -0,0 +1,49 @@
console.log("Initializing Kokoro TTS Worker");
import { KokoroTTS } from "https://cdn.jsdelivr.net/npm/kokoro-js@1.2.0/+esm";
async function detectWebGPU() {
try {
const adapter = await navigator.gpu.requestAdapter();
return !!adapter;
} catch (e) {
return false;
}
}
// Device detection
const device = (await detectWebGPU()) ? "webgpu" : "wasm";
self.postMessage({ status: "device", device });
console.log(`Detected device: ${device}`);
// Load the model
const model_id = "onnx-community/Kokoro-82M-v1.0-ONNX";
const tts = await KokoroTTS.from_pretrained(model_id, {
dtype: device === "wasm" ? "q8" : "fp32",
device,
});
console.log("Kokoro TTS model loaded successfully");
self.postMessage({ status: "ready", voices: tts.voices, device });
console.log("Available voices:", tts.voices);
// Listen for messages from the main thread
self.addEventListener("message", async (e) => {
const { text, voice } = e.data;
try {
// Generate speech
const audio = await tts.generate(text, { voice });
// Send the audio file back to the main thread
const blob = audio.toBlob();
self.postMessage({
status: "complete",
audio: URL.createObjectURL(blob),
text,
});
} catch (error) {
self.postMessage({ status: "error", error: error.message });
}
});

View File

@ -1,28 +1,34 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
} }
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["./*"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
} "**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"public/kokoro-worker.js"
],
"exclude": ["node_modules"]
}