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();
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
}
|
||||
console.log("code", code);
|
||||
if (redirectTo) {
|
||||
return NextResponse.redirect(`${origin}${redirectTo}`);
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import KokoroReader from "@/components/KokoroReader";
|
||||
import { NavActions } from "@/components/nav-actions";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@ -7,12 +6,7 @@ import {
|
||||
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,
|
||||
@ -20,18 +14,23 @@ import {
|
||||
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";
|
||||
|
||||
import { TTSProvider } from "@/components/TTSProvider";
|
||||
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
||||
|
||||
export default async function DocumentPage(props: { params: { id: string } }) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: userError,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (userError) {
|
||||
console.error("Error fetching user:", userError);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return redirect("/login");
|
||||
}
|
||||
@ -66,44 +65,46 @@ export default async function DocumentPage(props: { params: { id: string } }) {
|
||||
|
||||
const pages = (document.ocr_data as any).map((page: any) => page.markdown);
|
||||
|
||||
const processedContent = await remark()
|
||||
.use(remarkHtml)
|
||||
.process(pages.join("\n"));
|
||||
// Here we simply recreate the pages string:
|
||||
const rawContent =
|
||||
(document?.ocr_data as any)?.map((page: any) => page.markdown).join("\n") ||
|
||||
"";
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar
|
||||
documents={documents.map((d) => {
|
||||
return {
|
||||
name: d.file_name,
|
||||
url: `/dashboard/documents/${d.id}`,
|
||||
emoji: "📄",
|
||||
};
|
||||
})}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<header className="flex h-14 shrink-0 items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 px-3">
|
||||
<SidebarTrigger />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="line-clamp-1">
|
||||
{document.file_name || "Document Details"}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="ml-auto px-3">
|
||||
<NavActions pages={pages} />
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
<TTSProvider pages={pages}>
|
||||
<AppSidebar
|
||||
documents={documents.map((d) => {
|
||||
return {
|
||||
name: d.file_name,
|
||||
url: `/dashboard/documents/${d.id}`,
|
||||
emoji: "📄",
|
||||
};
|
||||
})}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<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 items-center gap-2 px-3">
|
||||
<SidebarTrigger />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="line-clamp-1">
|
||||
{document.file_name || "Document Details"}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-3">
|
||||
<NavActions pages={pages} />
|
||||
</div>
|
||||
</header>
|
||||
{/* <div
|
||||
className="prose mx-auto px-4 py-10
|
||||
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-img:rounded-lg prose-img:shadow-sm"
|
||||
dangerouslySetInnerHTML={{ __html: String(processedContent) }}
|
||||
></div>
|
||||
</SidebarInset>
|
||||
></div> */}
|
||||
<div className="mx-auto px-12 py-20 gap-2">
|
||||
<MarkdownRenderer rawContent={rawContent} />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</TTSProvider>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import { ThemeProvider } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const defaultUrl = process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
@ -15,8 +16,8 @@ const defaultUrl = process.env.VERCEL_URL
|
||||
|
||||
export const metadata = {
|
||||
metadataBase: new URL(defaultUrl),
|
||||
title: "Next.js and Supabase Starter Kit",
|
||||
description: "The fastest way to build apps with Next.js and Supabase",
|
||||
title: "Neuroread",
|
||||
description: "The easiest way to read articles and papers.",
|
||||
};
|
||||
|
||||
const geistSans = Geist({
|
||||
@ -38,7 +39,7 @@ export default function RootLayout({
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
<Toaster />
|
||||
<ThemeSwitcher />
|
||||
</ThemeProvider>
|
||||
|
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Play } from "lucide-react";
|
||||
import { Loader, Pause, Play } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@ -9,93 +9,54 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "./ui/accordion";
|
||||
import { Label } from "./ui/label";
|
||||
import { useTTS } from "./TTSProvider";
|
||||
|
||||
export default function KokoroReader({ pages }: { pages: any[] }) {
|
||||
// Create a reference to the worker object.
|
||||
const worker = useRef<Worker>(null);
|
||||
const {
|
||||
voices,
|
||||
selectedSpeaker,
|
||||
setSelectedSpeaker,
|
||||
skipToSentence,
|
||||
currentSentence,
|
||||
setCurrentSentence,
|
||||
playSentence,
|
||||
playInOrder,
|
||||
status,
|
||||
} = useTTS();
|
||||
|
||||
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 [playing, setPlaying] = useState(false);
|
||||
|
||||
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",
|
||||
});
|
||||
setCurrentSentence(0); // this might just jumpstart the audio
|
||||
}, [status === "ready"]);
|
||||
|
||||
console.log("Worker initialized");
|
||||
const play = () => {
|
||||
if (playing) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
setPlaying(true);
|
||||
playInOrder(currentSentence || 0);
|
||||
};
|
||||
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
|
||||
@ -123,53 +84,6 @@ export default function KokoroReader({ pages }: { pages: any[] }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
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,
|
||||
} from "@/components/ui/sidebar";
|
||||
import KokoroReader from "./KokoroReader";
|
||||
import { Slider } from "./ui/slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const data = [
|
||||
[
|
||||
@ -102,24 +104,38 @@ const data = [
|
||||
];
|
||||
|
||||
export function NavActions({ pages }: { pages: any[] }) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
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">
|
||||
Edit Oct 08
|
||||
</div> */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<ALargeSmall />
|
||||
</Button>
|
||||
</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>
|
||||
<PopoverTrigger asChild>
|
||||
@ -134,7 +150,7 @@ export function NavActions({ pages }: { pages: any[] }) {
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<Star />
|
||||
</Button>
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
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 { twMerge } from "tailwind-merge";
|
||||
import { visit } from "unist-util-visit";
|
||||
import { VFile } from "vfile";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@ -13,3 +15,43 @@ export async function detectWebGPU() {
|
||||
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-popover": "^1.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slider": "^1.3.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/supabase-js": "latest",
|
||||
"@tailwindcss/postcss": "^4.1.0",
|
||||
@ -35,8 +36,11 @@
|
||||
"prettier": "^3.3.3",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-html": "^16.0.1",
|
||||
"remove-markdown": "^0.6.0",
|
||||
"sonner": "^2.0.3",
|
||||
"sse.js": "^2.6.0",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
|
@ -22,17 +22,17 @@ export const updateSession = async (request: NextRequest) => {
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
request.cookies.set(name, value),
|
||||
request.cookies.set(name, value)
|
||||
);
|
||||
response = NextResponse.next({
|
||||
request,
|
||||
});
|
||||
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
|
||||
@ -40,12 +40,12 @@ export const updateSession = async (request: NextRequest) => {
|
||||
const user = await supabase.auth.getUser();
|
||||
|
||||
// protected routes
|
||||
if (request.nextUrl.pathname.startsWith("/protected") && user.error) {
|
||||
return NextResponse.redirect(new URL("/sign-in", request.url));
|
||||
if (request.nextUrl.pathname.startsWith("/dashboard") && user.error) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
|
||||
if (request.nextUrl.pathname === "/" && !user.error) {
|
||||
return NextResponse.redirect(new URL("/protected", request.url));
|
||||
return NextResponse.redirect(new URL("/dashboard", request.url));
|
||||
}
|
||||
|
||||
return response;
|
||||
|
Loading…
x
Reference in New Issue
Block a user