Implement WebGPU detection and integrate Kokoro TTS worker; update TypeScript config and add accordion component
This commit is contained in:
parent
5c303b594b
commit
73a0fba45e
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
175
components/KokoroReader.tsx
Normal file
175
components/KokoroReader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
66
components/ui/accordion.tsx
Normal file
66
components/ui/accordion.tsx
Normal 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 }
|
21
lib/utils.ts
21
lib/utils.ts
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
49
public/workers/kokoro-worker.js
Normal file
49
public/workers/kokoro-worker.js
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
@ -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"]
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user