rework markdown rendering and TTS
This commit is contained in:
parent
8f70d83785
commit
31e0848c95
@ -14,7 +14,6 @@ export async function GET(request: Request) {
|
|||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
await supabase.auth.exchangeCodeForSession(code);
|
await supabase.auth.exchangeCodeForSession(code);
|
||||||
}
|
}
|
||||||
console.log("code", code);
|
|
||||||
if (redirectTo) {
|
if (redirectTo) {
|
||||||
return NextResponse.redirect(`${origin}${redirectTo}`);
|
return NextResponse.redirect(`${origin}${redirectTo}`);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { AppSidebar } from "@/components/app-sidebar";
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
import KokoroReader from "@/components/KokoroReader";
|
|
||||||
import { NavActions } from "@/components/nav-actions";
|
import { NavActions } from "@/components/nav-actions";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@ -7,12 +6,7 @@ import {
|
|||||||
BreadcrumbList,
|
BreadcrumbList,
|
||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
} from "@/components/ui/breadcrumb";
|
} 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 { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
@ -20,18 +14,23 @@ import {
|
|||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { createClient } from "@/utils/supabase/server";
|
import { createClient } from "@/utils/supabase/server";
|
||||||
import { Speech } from "lucide-react";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { remark } from "remark";
|
|
||||||
import remarkHtml from "remark-html";
|
import { TTSProvider } from "@/components/TTSProvider";
|
||||||
|
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
||||||
|
|
||||||
export default async function DocumentPage(props: { params: { id: string } }) {
|
export default async function DocumentPage(props: { params: { id: string } }) {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { user },
|
data: { user },
|
||||||
|
error: userError,
|
||||||
} = await supabase.auth.getUser();
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (userError) {
|
||||||
|
console.error("Error fetching user:", userError);
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return redirect("/login");
|
return redirect("/login");
|
||||||
}
|
}
|
||||||
@ -66,12 +65,14 @@ export default async function DocumentPage(props: { params: { id: string } }) {
|
|||||||
|
|
||||||
const pages = (document.ocr_data as any).map((page: any) => page.markdown);
|
const pages = (document.ocr_data as any).map((page: any) => page.markdown);
|
||||||
|
|
||||||
const processedContent = await remark()
|
// Here we simply recreate the pages string:
|
||||||
.use(remarkHtml)
|
const rawContent =
|
||||||
.process(pages.join("\n"));
|
(document?.ocr_data as any)?.map((page: any) => page.markdown).join("\n") ||
|
||||||
|
"";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
|
<TTSProvider pages={pages}>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
documents={documents.map((d) => {
|
documents={documents.map((d) => {
|
||||||
return {
|
return {
|
||||||
@ -82,8 +83,8 @@ export default async function DocumentPage(props: { params: { id: string } }) {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="flex h-14 shrink-0 items-center gap-2">
|
<header className="grid grid-cols-2 h-14 items-center gap-2 sticky top-0 right-0 w-full bg-background border-b border-slate-800 z-10">
|
||||||
<div className="flex flex-1 items-center gap-2 px-3">
|
<div className="flex items-center gap-2 px-3">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
<Separator
|
<Separator
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
@ -99,11 +100,11 @@ export default async function DocumentPage(props: { params: { id: string } }) {
|
|||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto px-3">
|
<div className="flex items-center justify-end gap-2 px-3">
|
||||||
<NavActions pages={pages} />
|
<NavActions pages={pages} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div
|
{/* <div
|
||||||
className="prose mx-auto px-4 py-10
|
className="prose mx-auto px-4 py-10
|
||||||
text-white
|
text-white
|
||||||
prose-h1:font-semibold prose-h1:text-2xl prose-h1:mb-4 prose-h1:text-white
|
prose-h1:font-semibold prose-h1:text-2xl prose-h1:mb-4 prose-h1:text-white
|
||||||
@ -117,8 +118,12 @@ export default async function DocumentPage(props: { params: { id: string } }) {
|
|||||||
prose-code:bg-gray-800 prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:text-gray-200
|
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"
|
prose-img:rounded-lg prose-img:shadow-sm"
|
||||||
dangerouslySetInnerHTML={{ __html: String(processedContent) }}
|
dangerouslySetInnerHTML={{ __html: String(processedContent) }}
|
||||||
></div>
|
></div> */}
|
||||||
|
<div className="mx-auto px-12 py-20 gap-2">
|
||||||
|
<MarkdownRenderer rawContent={rawContent} />
|
||||||
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
</TTSProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ import { ThemeProvider } from "next-themes";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
const defaultUrl = process.env.VERCEL_URL
|
const defaultUrl = process.env.VERCEL_URL
|
||||||
? `https://${process.env.VERCEL_URL}`
|
? `https://${process.env.VERCEL_URL}`
|
||||||
@ -15,8 +16,8 @@ const defaultUrl = process.env.VERCEL_URL
|
|||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
metadataBase: new URL(defaultUrl),
|
metadataBase: new URL(defaultUrl),
|
||||||
title: "Next.js and Supabase Starter Kit",
|
title: "Neuroread",
|
||||||
description: "The fastest way to build apps with Next.js and Supabase",
|
description: "The easiest way to read articles and papers.",
|
||||||
};
|
};
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@ -38,7 +39,7 @@ export default function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
{children}
|
<TooltipProvider>{children}</TooltipProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useRef, useState, useEffect } from "react";
|
import { useRef, useState, useEffect } from "react";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Play } from "lucide-react";
|
import { Loader, Pause, Play } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
@ -9,93 +9,54 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "./ui/accordion";
|
} from "./ui/accordion";
|
||||||
import { Label } from "./ui/label";
|
import { Label } from "./ui/label";
|
||||||
|
import { useTTS } from "./TTSProvider";
|
||||||
|
|
||||||
export default function KokoroReader({ pages }: { pages: any[] }) {
|
export default function KokoroReader({ pages }: { pages: any[] }) {
|
||||||
// Create a reference to the worker object.
|
const {
|
||||||
const worker = useRef<Worker>(null);
|
voices,
|
||||||
|
selectedSpeaker,
|
||||||
|
setSelectedSpeaker,
|
||||||
|
skipToSentence,
|
||||||
|
currentSentence,
|
||||||
|
setCurrentSentence,
|
||||||
|
playSentence,
|
||||||
|
playInOrder,
|
||||||
|
status,
|
||||||
|
} = useTTS();
|
||||||
|
|
||||||
const [inputText, setInputText] = useState(
|
const [playing, setPlaying] = useState(false);
|
||||||
"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(() => {
|
useEffect(() => {
|
||||||
// Create the worker if it does not yet exist.
|
setCurrentSentence(0); // this might just jumpstart the audio
|
||||||
console.log("Initializing worker...");
|
}, [status === "ready"]);
|
||||||
worker.current ??= new Worker("/workers/kokoro-worker.js", {
|
|
||||||
type: "module",
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Worker initialized");
|
const play = () => {
|
||||||
|
if (playing) {
|
||||||
// Create a callback function for messages from the worker thread.
|
setPlaying(false);
|
||||||
const onMessageReceived = (e: any) => {
|
return;
|
||||||
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");
|
setPlaying(true);
|
||||||
|
playInOrder(currentSentence || 0);
|
||||||
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 (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center pt-4 relative overflow-hidden font-sans">
|
<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="max-w-3xl w-full relative z-[2]">
|
||||||
<div className="items-center justify-center text-center">
|
<div className="items-center justify-center text-center">
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
<Button
|
||||||
<Play />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={play}
|
||||||
|
disabled={status === null}
|
||||||
|
>
|
||||||
|
{status === "running" ? (
|
||||||
|
<Loader className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<span className="sr-only">Play</span>
|
||||||
|
)}
|
||||||
|
{playing ? <Pause /> : <Play />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -123,53 +84,6 @@ export default function KokoroReader({ pages }: { pages: any[] }) {
|
|||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
106
components/MarkdownRenderer.tsx
Normal file
106
components/MarkdownRenderer.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import ReactMarkdown, { Components } from "react-markdown";
|
||||||
|
import rehypeRaw from "rehype-raw";
|
||||||
|
import { useTTS } from "./TTSProvider";
|
||||||
|
import rehypeHighlight from "@/lib/utils";
|
||||||
|
|
||||||
|
// Utility to escape regex special characters:
|
||||||
|
function escapeRegExp(text: string) {
|
||||||
|
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarkdownRenderer({
|
||||||
|
rawContent,
|
||||||
|
}: {
|
||||||
|
rawContent: string;
|
||||||
|
}) {
|
||||||
|
// Obtain TTS info from context.
|
||||||
|
// TTSProvider is already wrapping this component higher in the tree.
|
||||||
|
const { currentSentence, sentences } = useTTS();
|
||||||
|
|
||||||
|
// Determine the text to highlight.
|
||||||
|
const textToHighlight = useMemo(() => {
|
||||||
|
if (!sentences || sentences.length === 0) return "";
|
||||||
|
return sentences[currentSentence] || "";
|
||||||
|
}, [sentences, currentSentence]);
|
||||||
|
|
||||||
|
// Setup rehype plugins including our highlight plugin.
|
||||||
|
const rehypePlugins = useMemo(
|
||||||
|
() => [rehypeRaw, [rehypeHighlight, { textToHighlight }] as any],
|
||||||
|
[textToHighlight]
|
||||||
|
);
|
||||||
|
|
||||||
|
const components: Components = {
|
||||||
|
h1: ({ node, ...props }) => (
|
||||||
|
<h1 className="text-2xl font-semibold mb-4 text-white" {...props} />
|
||||||
|
),
|
||||||
|
h2: ({ node, ...props }) => (
|
||||||
|
<h2 className="text-xl font-medium mb-3 text-white" {...props} />
|
||||||
|
),
|
||||||
|
h3: ({ node, ...props }) => (
|
||||||
|
<h3 className="text-lg font-medium mb-2 text-gray-300" {...props} />
|
||||||
|
),
|
||||||
|
h4: ({ node, ...props }) => (
|
||||||
|
<h4 className="text-lg font-medium mb-2 text-gray-300" {...props} />
|
||||||
|
),
|
||||||
|
p: ({ node, ...props }) => (
|
||||||
|
<p className="leading-7 text-gray-200" {...props} />
|
||||||
|
),
|
||||||
|
img: ({ node, ...props }) => (
|
||||||
|
<img
|
||||||
|
className="rounded-lg shadow-sm"
|
||||||
|
style={{ maxWidth: "100%", height: "auto" }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
a: ({ node, ...props }) => (
|
||||||
|
<a className="text-blue-400 hover:underline" {...props} />
|
||||||
|
),
|
||||||
|
strong: ({ node, ...props }) => (
|
||||||
|
<strong className="text-gray-200 font-semibold" {...props} />
|
||||||
|
),
|
||||||
|
blockquote: ({ node, ...props }) => (
|
||||||
|
<blockquote
|
||||||
|
className="italic border-l-4 pl-4 border-gray-600 text-gray-300"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
code: ({ node, ...props }) => (
|
||||||
|
<code
|
||||||
|
className="bg-gray-800 rounded px-1 py-0.5 text-gray-200"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
sup: ({ node, ...props }) => (
|
||||||
|
// TODO: get the references from the document and display them in a popover
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<sup
|
||||||
|
className="text-gray-200 cursor-pointer underline hover:cursor-pointer"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-56 overflow-hidden rounded-lg p-0">
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Replace with actual reference content */}
|
||||||
|
<p>Reference content goes here.</p>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactMarkdown
|
||||||
|
children={rawContent}
|
||||||
|
components={components}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
265
components/TTSProvider.tsx
Normal file
265
components/TTSProvider.tsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
"use client";
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import removeMarkdown from "remove-markdown";
|
||||||
|
|
||||||
|
// More robust sentence splitter using Intl.Segmenter for better accuracy.
|
||||||
|
function splitIntoSentences(text: string): string[] {
|
||||||
|
if (typeof Intl !== "undefined" && Intl.Segmenter) {
|
||||||
|
const segmenter = new Intl.Segmenter("en", { granularity: "sentence" });
|
||||||
|
return Array.from(segmenter.segment(text)).map(
|
||||||
|
(segment) => segment.segment
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fallback to regex-based splitting if Intl.Segmenter is unavailable.
|
||||||
|
return text.match(/[^\.!\?]+[\.!\?]+/g) || [text];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TTSContextType {
|
||||||
|
sentences: string[];
|
||||||
|
currentSentence: number;
|
||||||
|
ttsBuffer: (string | null)[];
|
||||||
|
voices: any[];
|
||||||
|
selectedSpeaker: string;
|
||||||
|
status: "ready" | "running" | null;
|
||||||
|
setSelectedSpeaker: (speaker: string) => void;
|
||||||
|
setCurrentSentence: (index: number) => void;
|
||||||
|
playSentence: (index: number) => void;
|
||||||
|
skipToSentence: (index: number) => void;
|
||||||
|
playInOrder: (index: number) => void;
|
||||||
|
pause: () => void;
|
||||||
|
resume: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const TTSProvider = ({
|
||||||
|
pages,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
pages: string[];
|
||||||
|
children: ReactNode;
|
||||||
|
}) => {
|
||||||
|
// Combine pages and split into sentences.
|
||||||
|
const fullText = pages.join("\n");
|
||||||
|
const sentences = splitIntoSentences(fullText);
|
||||||
|
|
||||||
|
const [currentSentence, setCurrentSentence] = useState(0);
|
||||||
|
const [ttsBuffer, setTtsBuffer] = useState<(string | null)[]>(
|
||||||
|
Array(sentences.length).fill(null)
|
||||||
|
);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
|
// Create a reference to the worker object.
|
||||||
|
const worker = useRef<Worker>(null);
|
||||||
|
|
||||||
|
const [selectedSpeaker, setSelectedSpeaker] = useState("af_heart");
|
||||||
|
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [sentence, setSentence] = useState<number>();
|
||||||
|
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 }[]>([]);
|
||||||
|
|
||||||
|
async function generateTTSForIndex(
|
||||||
|
sentence: string,
|
||||||
|
index: number
|
||||||
|
): Promise<string> {
|
||||||
|
const key = `tts-${index}`;
|
||||||
|
const cached = localStorage.getItem(key);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
worker.current!.postMessage({
|
||||||
|
type: "generate",
|
||||||
|
text: sentence,
|
||||||
|
voice: selectedSpeaker,
|
||||||
|
});
|
||||||
|
setStatus("running");
|
||||||
|
setLoadingMessage("Generating audio...");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
worker.current!.addEventListener(
|
||||||
|
"message",
|
||||||
|
(e: any) => {
|
||||||
|
if (e.data.status === "complete") {
|
||||||
|
localStorage.setItem(key, e.data.audio);
|
||||||
|
resolve(e.data.audio);
|
||||||
|
} else if (e.data.status === "error") {
|
||||||
|
reject(e.data.error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Pre-buffer current and next 2 sentences.
|
||||||
|
useEffect(() => {
|
||||||
|
async function preloadBuffer() {
|
||||||
|
const newBuffer = [...ttsBuffer];
|
||||||
|
const end = Math.min(sentences.length, currentSentence + 3);
|
||||||
|
for (let i = currentSentence; i < end; i++) {
|
||||||
|
if (!newBuffer[i]) {
|
||||||
|
newBuffer[i] = await generateTTSForIndex(
|
||||||
|
removeMarkdown(sentences[i]),
|
||||||
|
i
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTtsBuffer(newBuffer);
|
||||||
|
}
|
||||||
|
preloadBuffer();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentSentence, sentences.join(" ")]);
|
||||||
|
|
||||||
|
const playSentence = async (index: number) => {
|
||||||
|
setCurrentSentence(index);
|
||||||
|
let audioUrl = ttsBuffer[index];
|
||||||
|
if (!audioUrl) {
|
||||||
|
audioUrl = await generateTTSForIndex(
|
||||||
|
removeMarkdown(sentences[index]),
|
||||||
|
index
|
||||||
|
);
|
||||||
|
setTtsBuffer((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[index] = audioUrl;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.src = audioUrl;
|
||||||
|
await audioRef.current.play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipToSentence = (index: number) => {
|
||||||
|
if (index < 0 || index >= sentences.length) return;
|
||||||
|
playSentence(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const playInOrder = async (index: number) => {
|
||||||
|
if (index < 0 || index >= sentences.length) return;
|
||||||
|
setCurrentSentence(index);
|
||||||
|
for (let i = index; i < sentences.length; i++) {
|
||||||
|
await playSentence(i);
|
||||||
|
if (i < sentences.length - 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resume = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: TTSContextType = {
|
||||||
|
sentences,
|
||||||
|
currentSentence,
|
||||||
|
ttsBuffer,
|
||||||
|
voices,
|
||||||
|
playSentence,
|
||||||
|
skipToSentence,
|
||||||
|
selectedSpeaker,
|
||||||
|
setSelectedSpeaker,
|
||||||
|
setCurrentSentence,
|
||||||
|
playInOrder,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TTSContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
{/* Hidden audio element used for playback */}
|
||||||
|
<audio ref={audioRef} style={{ display: "none" }} />
|
||||||
|
</TTSContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTTS = (): TTSContextType => {
|
||||||
|
const context = useContext(TTSContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useTTS must be used within a TTSProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
@ -37,6 +37,8 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import KokoroReader from "./KokoroReader";
|
import KokoroReader from "./KokoroReader";
|
||||||
|
import { Slider } from "./ui/slider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const data = [
|
const data = [
|
||||||
[
|
[
|
||||||
@ -102,24 +104,38 @@ const data = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function NavActions({ pages }: { pages: any[] }) {
|
export function NavActions({ pages }: { pages: any[] }) {
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setIsOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
{/* <div className="text-muted-foreground hidden font-medium md:inline-block">
|
|
||||||
Edit Oct 08
|
|
||||||
</div> */}
|
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||||
<ALargeSmall />
|
<ALargeSmall />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent></PopoverContent>
|
<PopoverContent>
|
||||||
|
<div className="grid grid-cols-5 justify-center">
|
||||||
|
{["sm", "md", "lg", "xl", "2xl"].map((size) => (
|
||||||
|
<Button
|
||||||
|
key={size}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-7 uppercase text-right",
|
||||||
|
size === "md" && "bg-accent data-[state=open]:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{size}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
defaultValue={[3]}
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
step={1}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@ -134,7 +150,7 @@ export function NavActions({ pages }: { pages: any[] }) {
|
|||||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||||
<Star />
|
<Star />
|
||||||
</Button>
|
</Button>
|
||||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
63
components/ui/slider.tsx
Normal file
63
components/ui/slider.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
|
const _values = React.useMemo(
|
||||||
|
() =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: Array.isArray(defaultValue)
|
||||||
|
? defaultValue
|
||||||
|
: [min, max],
|
||||||
|
[value, defaultValue, min, max]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
data-slot="slider"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
data-slot="slider-track"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Range
|
||||||
|
data-slot="slider-range"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{Array.from({ length: _values.length }, (_, index) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
key={index}
|
||||||
|
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider }
|
42
lib/utils.ts
42
lib/utils.ts
@ -1,5 +1,7 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { visit } from "unist-util-visit";
|
||||||
|
import { VFile } from "vfile";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
@ -13,3 +15,43 @@ export async function detectWebGPU() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom rehype plugin that wraps the first occurrence of "textToHighlight" in a <mark> tag.
|
||||||
|
* options: { textToHighlight: string }
|
||||||
|
*/
|
||||||
|
export default function rehypeHighlight(options: { textToHighlight: string }) {
|
||||||
|
return (tree: any, file: VFile) => {
|
||||||
|
let found = false;
|
||||||
|
visit(tree, "text", (node: any) => {
|
||||||
|
if (found) return;
|
||||||
|
const { value } = node;
|
||||||
|
const text = options.textToHighlight;
|
||||||
|
const index = value.toLowerCase().indexOf(text.toLowerCase());
|
||||||
|
if (index !== -1) {
|
||||||
|
found = true;
|
||||||
|
// Split the text into three parts: before, match, and after.
|
||||||
|
const before = value.slice(0, index);
|
||||||
|
const match = value.slice(index, index + text.length);
|
||||||
|
const after = value.slice(index + text.length);
|
||||||
|
// Replace the current node with three nodes.
|
||||||
|
node.type = "element";
|
||||||
|
node.tagName = "span";
|
||||||
|
node.properties = {};
|
||||||
|
node.children = [];
|
||||||
|
if (before) {
|
||||||
|
node.children.push({ type: "text", value: before });
|
||||||
|
}
|
||||||
|
node.children.push({
|
||||||
|
type: "element",
|
||||||
|
tagName: "mark",
|
||||||
|
properties: { style: "background-color: yellow;" },
|
||||||
|
children: [{ type: "text", value: match }],
|
||||||
|
});
|
||||||
|
if (after) {
|
||||||
|
node.children.push({ type: "text", value: after });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -17,8 +17,9 @@
|
|||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-popover": "^1.1.6",
|
"@radix-ui/react-popover": "^1.1.6",
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
|
"@radix-ui/react-slider": "^1.3.2",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.2.4",
|
||||||
"@supabase/ssr": "latest",
|
"@supabase/ssr": "latest",
|
||||||
"@supabase/supabase-js": "latest",
|
"@supabase/supabase-js": "latest",
|
||||||
"@tailwindcss/postcss": "^4.1.0",
|
"@tailwindcss/postcss": "^4.1.0",
|
||||||
@ -35,8 +36,11 @@
|
|||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
"remark": "^15.0.1",
|
"remark": "^15.0.1",
|
||||||
"remark-html": "^16.0.1",
|
"remark-html": "^16.0.1",
|
||||||
|
"remove-markdown": "^0.6.0",
|
||||||
"sonner": "^2.0.3",
|
"sonner": "^2.0.3",
|
||||||
"sse.js": "^2.6.0",
|
"sse.js": "^2.6.0",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
|
@ -22,17 +22,17 @@ export const updateSession = async (request: NextRequest) => {
|
|||||||
},
|
},
|
||||||
setAll(cookiesToSet) {
|
setAll(cookiesToSet) {
|
||||||
cookiesToSet.forEach(({ name, value }) =>
|
cookiesToSet.forEach(({ name, value }) =>
|
||||||
request.cookies.set(name, value),
|
request.cookies.set(name, value)
|
||||||
);
|
);
|
||||||
response = NextResponse.next({
|
response = NextResponse.next({
|
||||||
request,
|
request,
|
||||||
});
|
});
|
||||||
cookiesToSet.forEach(({ name, value, options }) =>
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
response.cookies.set(name, value, options),
|
response.cookies.set(name, value, options)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// This will refresh session if expired - required for Server Components
|
// This will refresh session if expired - required for Server Components
|
||||||
@ -40,12 +40,12 @@ export const updateSession = async (request: NextRequest) => {
|
|||||||
const user = await supabase.auth.getUser();
|
const user = await supabase.auth.getUser();
|
||||||
|
|
||||||
// protected routes
|
// protected routes
|
||||||
if (request.nextUrl.pathname.startsWith("/protected") && user.error) {
|
if (request.nextUrl.pathname.startsWith("/dashboard") && user.error) {
|
||||||
return NextResponse.redirect(new URL("/sign-in", request.url));
|
return NextResponse.redirect(new URL("/login", request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.nextUrl.pathname === "/" && !user.error) {
|
if (request.nextUrl.pathname === "/" && !user.error) {
|
||||||
return NextResponse.redirect(new URL("/protected", request.url));
|
return NextResponse.redirect(new URL("/dashboard", request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user