diff --git a/app/dashboard/documents/[id]/page.tsx b/app/dashboard/documents/[id]/page.tsx
index 362a26b..1884fd7 100644
--- a/app/dashboard/documents/[id]/page.tsx
+++ b/app/dashboard/documents/[id]/page.tsx
@@ -1,117 +1,126 @@
-import { AppSidebar } from "@/components/app-sidebar";
-import { NavActions } from "@/components/nav-actions";
-import {
- Breadcrumb,
- BreadcrumbItem,
- BreadcrumbList,
- BreadcrumbPage,
-} from "@/components/ui/breadcrumb";
-import { Separator } from "@/components/ui/separator";
-import {
- SidebarInset,
- SidebarProvider,
- SidebarTrigger,
-} from "@/components/ui/sidebar";
-import { createClient } from "@/utils/supabase/server";
-import { redirect } from "next/navigation";
-import { remark } from "remark";
-import remarkHtml from "remark-html";
-
-export default async function DocumentPage({
- params,
-}: {
- params: { id: string };
-}) {
- const supabase = await createClient();
-
- const {
- data: { user },
- } = await supabase.auth.getUser();
-
- if (!user) {
- return redirect("/login");
- }
-
- // Fetch the document details based on the ID from params
- const { data: document, error } = await supabase
- .from("documents")
- .select("*")
- .eq("id", params.id)
- .single();
-
- if (error || !document) {
- console.error("Error fetching document:", error);
- }
-
- // If the document doesn't exist, redirect to the documents page or handle it accordingly
- if (!document) {
- return redirect("/dashboard");
- }
- const { data: documents, error: documentsError } = await supabase
- .from("documents")
- .select("id, file_name, created_at, owner")
- .eq("owner", user.id)
- .order("created_at", { ascending: false });
-
- if (documentsError) {
- console.error("Error fetching documents:", error);
- return
Error loading documents.
;
- }
-
- const pages = (document.ocr_data as any).pages.map(
- (page: any) => page.markdown
- );
-
- const processedContent = await remark()
- .use(remarkHtml)
- .process(pages.join(" "));
-
- return (
-
- {
- return {
- name: d.file_name,
- url: `/dashboard/documents/${d.id}`,
- emoji: "📄",
- };
- })}
- />
-
-
-
-
-
- );
-}
+import { AppSidebar } from "@/components/app-sidebar";
+import KokoroReader from "@/components/KokoroReader";
+import { NavActions } from "@/components/nav-actions";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbList,
+ BreadcrumbPage,
+} from "@/components/ui/breadcrumb";
+import { Button } from "@/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Separator } from "@/components/ui/separator";
+import {
+ SidebarInset,
+ SidebarProvider,
+ SidebarTrigger,
+} from "@/components/ui/sidebar";
+import { createClient } from "@/utils/supabase/server";
+import { Speech } from "lucide-react";
+import { redirect } from "next/navigation";
+import { remark } from "remark";
+import remarkHtml from "remark-html";
+
+export default async function DocumentPage({
+ params,
+}: {
+ params: { id: string };
+}) {
+ const supabase = await createClient();
+
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ return redirect("/login");
+ }
+
+ // Fetch the document details based on the ID from params
+ const { data: document, error } = await supabase
+ .from("documents")
+ .select("*")
+ .eq("id", params.id)
+ .single();
+
+ if (error || !document) {
+ console.error("Error fetching document:", error);
+ }
+
+ // If the document doesn't exist, redirect to the documents page or handle it accordingly
+ if (!document) {
+ return redirect("/dashboard");
+ }
+ const { data: documents, error: documentsError } = await supabase
+ .from("documents")
+ .select("id, file_name, created_at, owner")
+ .eq("owner", user.id)
+ .order("created_at", { ascending: false });
+
+ if (documentsError) {
+ console.error("Error fetching documents:", error);
+ return Error loading documents.
;
+ }
+
+ const pages = (document.ocr_data as any).pages.map(
+ (page: any) => page.markdown
+ );
+
+ const processedContent = await remark()
+ .use(remarkHtml)
+ .process(pages.join(" "));
+
+ return (
+
+ {
+ return {
+ name: d.file_name,
+ url: `/dashboard/documents/${d.id}`,
+ emoji: "📄",
+ };
+ })}
+ />
+
+
+
+
+
+ );
+}
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index 6f6cb89..330b40a 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -1,61 +1,85 @@
-import { AppSidebar } from "@/components/app-sidebar";
-import { NavActions } from "@/components/nav-actions";
-import {
- Breadcrumb,
- BreadcrumbItem,
- BreadcrumbList,
- BreadcrumbPage,
-} from "@/components/ui/breadcrumb";
-import { Separator } from "@/components/ui/separator";
-import {
- SidebarInset,
- SidebarProvider,
- SidebarTrigger,
-} from "@/components/ui/sidebar";
-import { createClient } from "@/utils/supabase/server";
-import { redirect } from "next/navigation";
-
-export default async function Page() {
- const supabase = await createClient();
-
- const {
- data: { user },
- } = await supabase.auth.getUser();
-
- if (!user) {
- return redirect("/login");
- }
-
- return (
-
-
-
-
-
-
-
- );
-}
+import { AppSidebar } from "@/components/app-sidebar";
+import { NavActions } from "@/components/nav-actions";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbList,
+ BreadcrumbPage,
+} from "@/components/ui/breadcrumb";
+import { Separator } from "@/components/ui/separator";
+import {
+ SidebarInset,
+ SidebarProvider,
+ SidebarTrigger,
+} from "@/components/ui/sidebar";
+import { createClient } from "@/utils/supabase/server";
+import { redirect } from "next/navigation";
+
+export default async function Page() {
+ const supabase = await createClient();
+
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ return redirect("/login");
+ }
+
+ const { data: documents, error } = await supabase
+ .from("documents")
+ .select("*")
+ .eq("owner", user.id)
+ .order("created_at", { ascending: false });
+
+ if (error) {
+ console.error("Failed to fetch documents:", error);
+ // Optionally handle the error, e.g., show a message to the user
+ return (
+
+
Failed to load documents.
+
+ );
+ }
+
+ return (
+
+ {
+ return {
+ name: d.file_name,
+ url: `/dashboard/documents/${d.id}`,
+ emoji: "📄",
+ };
+ })}
+ />
+
+
+
+
+
+ );
+}
diff --git a/bun.lockb b/bun.lockb
index e6603c5..c9a33ab 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/components/KokoroReader.tsx b/components/KokoroReader.tsx
new file mode 100644
index 0000000..90faccf
--- /dev/null
+++ b/components/KokoroReader.tsx
@@ -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(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([]);
+ 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 (
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+
+ {/*
*/}
+
+ {results.map((result, i) => (
+
+
+
+ #{results.length - i}
+
+
{result.text}
+
+
+
+ ))}
+
+ );
+}
diff --git a/components/nav-actions.tsx b/components/nav-actions.tsx
index 8e45ba8..06e4ab0 100644
--- a/components/nav-actions.tsx
+++ b/components/nav-actions.tsx
@@ -1,153 +1,165 @@
-"use client"
-
-import * as React from "react"
-import {
- ArrowDown,
- ArrowUp,
- Bell,
- Copy,
- CornerUpLeft,
- CornerUpRight,
- FileText,
- GalleryVerticalEnd,
- LineChart,
- Link,
- MoreHorizontal,
- Settings2,
- Star,
- Trash,
- Trash2,
-} from "lucide-react"
-
-import { Button } from "@/components/ui/button"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import {
- Sidebar,
- SidebarContent,
- SidebarGroup,
- SidebarGroupContent,
- SidebarMenu,
- SidebarMenuButton,
- SidebarMenuItem,
-} from "@/components/ui/sidebar"
-
-const data = [
- [
- {
- label: "Customize Page",
- icon: Settings2,
- },
- {
- label: "Turn into wiki",
- icon: FileText,
- },
- ],
- [
- {
- label: "Copy Link",
- icon: Link,
- },
- {
- label: "Duplicate",
- icon: Copy,
- },
- {
- label: "Move to",
- icon: CornerUpRight,
- },
- {
- label: "Move to Trash",
- icon: Trash2,
- },
- ],
- [
- {
- label: "Undo",
- icon: CornerUpLeft,
- },
- {
- label: "View analytics",
- icon: LineChart,
- },
- {
- label: "Version History",
- icon: GalleryVerticalEnd,
- },
- {
- label: "Show delete pages",
- icon: Trash,
- },
- {
- label: "Notifications",
- icon: Bell,
- },
- ],
- [
- {
- label: "Import",
- icon: ArrowUp,
- },
- {
- label: "Export",
- icon: ArrowDown,
- },
- ],
-]
-
-export function NavActions() {
- const [isOpen, setIsOpen] = React.useState(false)
-
- React.useEffect(() => {
- setIsOpen(true)
- }, [])
-
- return (
-
-
- Edit Oct 08
-
-
-
-
-
-
-
-
-
- {data.map((group, index) => (
-
-
-
- {group.map((item, index) => (
-
-
- {item.label}
-
-
- ))}
-
-
-
- ))}
-
-
-
-
-
- )
-}
+"use client";
+
+import * as React from "react";
+import {
+ ArrowDown,
+ ArrowUp,
+ Bell,
+ Copy,
+ CornerUpLeft,
+ CornerUpRight,
+ FileText,
+ GalleryVerticalEnd,
+ LineChart,
+ Link,
+ MoreHorizontal,
+ Settings2,
+ Speech,
+ Star,
+ Trash,
+ Trash2,
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from "@/components/ui/sidebar";
+import KokoroReader from "./KokoroReader";
+
+const data = [
+ [
+ {
+ label: "Customize Page",
+ icon: Settings2,
+ },
+ {
+ label: "Turn into wiki",
+ icon: FileText,
+ },
+ ],
+ [
+ {
+ label: "Copy Link",
+ icon: Link,
+ },
+ {
+ label: "Duplicate",
+ icon: Copy,
+ },
+ {
+ label: "Move to",
+ icon: CornerUpRight,
+ },
+ {
+ label: "Move to Trash",
+ icon: Trash2,
+ },
+ ],
+ [
+ {
+ label: "Undo",
+ icon: CornerUpLeft,
+ },
+ {
+ label: "View analytics",
+ icon: LineChart,
+ },
+ {
+ label: "Version History",
+ icon: GalleryVerticalEnd,
+ },
+ {
+ label: "Show delete pages",
+ icon: Trash,
+ },
+ {
+ label: "Notifications",
+ icon: Bell,
+ },
+ ],
+ [
+ {
+ label: "Import",
+ icon: ArrowUp,
+ },
+ {
+ label: "Export",
+ icon: ArrowDown,
+ },
+ ],
+];
+
+export function NavActions({ pages }: { pages: any[] }) {
+ const [isOpen, setIsOpen] = React.useState(false);
+
+ React.useEffect(() => {
+ setIsOpen(true);
+ }, []);
+
+ return (
+
+ {/*
+ Edit Oct 08
+
*/}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {data.map((group, index) => (
+
+
+
+ {group.map((item, index) => (
+
+
+ {item.label}
+
+
+ ))}
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx
new file mode 100644
index 0000000..4a8cca4
--- /dev/null
+++ b/components/ui/accordion.tsx
@@ -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) {
+ return
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/lib/utils.ts b/lib/utils.ts
index f021764..5c66370 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -1,6 +1,15 @@
-import { clsx, type ClassValue } from "clsx";
-import { twMerge } from "tailwind-merge";
-
-export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs));
-}
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export async function detectWebGPU() {
+ try {
+ const adapter = await navigator.gpu.requestAdapter();
+ return !!adapter;
+ } catch (e) {
+ return false;
+ }
+}
diff --git a/next.config.ts b/next.config.ts
index f20f687..cbe008f 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,21 @@
-import type { NextConfig } from "next";
-
-const nextConfig: NextConfig = {
- /* config options here */
-};
-
-export default nextConfig;
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ /* config options here */
+ webpack: (config, { isServer }) => {
+ if (!isServer) {
+ 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;
diff --git a/package.json b/package.json
index dcaf4c6..f4b99eb 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"dependencies": {
"@ai-sdk/mistral": "^1.2.3",
"@mistralai/mistralai": "^1.5.2",
+ "@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
@@ -26,6 +27,7 @@
"autoprefixer": "10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "kokoro-js": "^1.2.0",
"lucide-react": "^0.486.0",
"next": "latest",
"next-themes": "^0.4.3",
@@ -38,6 +40,7 @@
"zod": "^3.24.2"
},
"devDependencies": {
+ "@open-wc/webpack-import-meta-loader": "^0.4.7",
"@types/node": "22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "19.0.2",
@@ -46,6 +49,7 @@
"tailwind-merge": "^2.5.2",
"tailwindcss": "^4.1.0",
"tailwindcss-animate": "^1.0.7",
- "typescript": "5.7.2"
+ "typescript": "5.7.2",
+ "worker-loader": "^3.0.8"
}
}
diff --git a/public/workers/kokoro-worker.js b/public/workers/kokoro-worker.js
new file mode 100644
index 0000000..3b95646
--- /dev/null
+++ b/public/workers/kokoro-worker.js
@@ -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 });
+ }
+});
diff --git a/tsconfig.json b/tsconfig.json
index 0c767fb..850eab4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,28 +1,34 @@
-{
- "compilerOptions": {
- "target": "es5",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "strict": true,
- "forceConsistentCasingInFileNames": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "node",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "preserve",
- "incremental": true,
- "plugins": [
- {
- "name": "next"
- }
- ],
- "paths": {
- "@/*": ["./*"]
- }
- },
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
-}
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ "public/kokoro-worker.js"
+ ],
+ "exclude": ["node_modules"]
+}