mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 17:26:23 +02:00
Merge pull request #430 from CREDO23/feat/chat-pannel
[Feature] Add the chat panel
This commit is contained in:
commit
0835a192a2
38 changed files with 1219 additions and 72 deletions
|
|
@ -1,9 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { type ChatHandler, ChatSection as LlamaIndexChatSection } from "@llamaindex/chat-ui";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { ChatInputUI } from "@/components/chat/ChatInputGroup";
|
||||
import { ChatMessagesUI } from "@/components/chat/ChatMessages";
|
||||
import type { Document } from "@/hooks/use-documents";
|
||||
import { activeChatIdAtom } from "@/stores/chat/active-chat.atom";
|
||||
import { ChatPanelContainer } from "./ChatPanel/ChatPanelContainer";
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
handler: ChatHandler;
|
||||
|
|
@ -28,9 +33,18 @@ export default function ChatInterface({
|
|||
topK = 10,
|
||||
onTopKChange,
|
||||
}: ChatInterfaceProps) {
|
||||
const { chat_id, search_space_id } = useParams();
|
||||
const setActiveChatIdState = useSetAtom(activeChatIdAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const id = typeof chat_id === "string" ? chat_id : chat_id ? chat_id[0] : "";
|
||||
if (!id) return;
|
||||
setActiveChatIdState(id);
|
||||
}, [chat_id, search_space_id]);
|
||||
|
||||
return (
|
||||
<LlamaIndexChatSection handler={handler} className="flex h-full">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex grow-1 flex-col">
|
||||
<ChatMessagesUI />
|
||||
<div className="border-t p-4">
|
||||
<ChatInputUI
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
"use client";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { generatePodcast } from "@/lib/apis/podcast-apis";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { activeChatAtom, activeChatIdAtom } from "@/stores/chat/active-chat.atom";
|
||||
import { chatUIAtom } from "@/stores/chat/chat-ui.atom";
|
||||
import { ChatPanelView } from "./ChatPanelView";
|
||||
|
||||
export interface GeneratePodcastRequest {
|
||||
type: "CHAT" | "DOCUMENT";
|
||||
ids: number[];
|
||||
search_space_id: number;
|
||||
podcast_title?: string;
|
||||
user_prompt?: string;
|
||||
}
|
||||
|
||||
export function ChatPanelContainer() {
|
||||
const {
|
||||
data: activeChatState,
|
||||
isLoading: isChatLoading,
|
||||
error: chatError,
|
||||
} = useAtomValue(activeChatAtom);
|
||||
const activeChatIdState = useAtomValue(activeChatIdAtom);
|
||||
const authToken = localStorage.getItem("surfsense_bearer_token");
|
||||
const { isChatPannelOpen } = useAtomValue(chatUIAtom);
|
||||
|
||||
const handleGeneratePodcast = async (request: GeneratePodcastRequest) => {
|
||||
try {
|
||||
if (!authToken) {
|
||||
throw new Error("Authentication error. Please log in again.");
|
||||
}
|
||||
await generatePodcast(request, authToken);
|
||||
toast.success(`Podcast generation started!`);
|
||||
} catch (error) {
|
||||
toast.error("Error generating podcast. Please log in again.");
|
||||
console.error("Error generating podcast:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return activeChatIdState ? (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 flex flex-col h-full transition-all",
|
||||
isChatPannelOpen ? "w-64" : "w-0"
|
||||
)}
|
||||
>
|
||||
{isChatLoading || chatError ? (
|
||||
<div className="border-b p-2">
|
||||
{isChatLoading ? (
|
||||
<div title="Loading chat" className="flex items-center justify-center h-full">
|
||||
<LoaderIcon strokeWidth={1.5} className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : chatError ? (
|
||||
<div title="Failed to load chat" className="flex items-center justify-center h-full">
|
||||
<TriangleAlert strokeWidth={1.5} className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isChatLoading && !chatError && activeChatState?.chatDetails && (
|
||||
<ChatPanelView generatePodcast={handleGeneratePodcast} />
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
147
surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx
Normal file
147
surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { AlertCircle, Pencil, Play, Podcast, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useContext, useTransition } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { activeChatAtom } from "@/stores/chat/active-chat.atom";
|
||||
import { chatUIAtom } from "@/stores/chat/chat-ui.atom";
|
||||
import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils";
|
||||
import type { GeneratePodcastRequest } from "./ChatPanelContainer";
|
||||
import { ConfigModal } from "./ConfigModal";
|
||||
import { PodcastPlayer } from "./PodcastPlayer";
|
||||
|
||||
interface ChatPanelViewProps {
|
||||
generatePodcast: (request: GeneratePodcastRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ChatPanelView(props: ChatPanelViewProps) {
|
||||
const [chatUIState, setChatUIState] = useAtom(chatUIAtom);
|
||||
const { data: activeChatState } = useAtomValue(activeChatAtom);
|
||||
|
||||
const { isChatPannelOpen } = chatUIState;
|
||||
const podcast = activeChatState?.podcast;
|
||||
const chatDetails = activeChatState?.chatDetails;
|
||||
|
||||
const { generatePodcast } = props;
|
||||
|
||||
// Check if podcast is stale
|
||||
const podcastIsStale =
|
||||
podcast && chatDetails && isPodcastStale(chatDetails.state_version, podcast.chat_state_version);
|
||||
|
||||
const handleGeneratePost = useCallback(async () => {
|
||||
if (!chatDetails) return;
|
||||
await generatePodcast({
|
||||
type: "CHAT",
|
||||
ids: [chatDetails.id],
|
||||
search_space_id: chatDetails.search_space_id,
|
||||
podcast_title: chatDetails.title,
|
||||
});
|
||||
}, [chatDetails, generatePodcast]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"w-full cursor-pointer p-4 border-b",
|
||||
!isChatPannelOpen && "flex items-center justify-center"
|
||||
)}
|
||||
title={podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
|
||||
>
|
||||
{isChatPannelOpen ? (
|
||||
<div className="space-y-3">
|
||||
{/* Show stale podcast warning if applicable */}
|
||||
{podcastIsStale && (
|
||||
<div className="rounded-lg p-3 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex gap-2 items-start">
|
||||
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<p className="font-medium">Podcast is outdated</p>
|
||||
<p className="text-xs mt-1 opacity-90">
|
||||
{getPodcastStalenessMessage(
|
||||
chatDetails?.state_version || 0,
|
||||
podcast?.chat_state_version
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleGeneratePost}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleGeneratePost();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full space-y-3 rounded-xl p-3 transition-colors",
|
||||
podcastIsStale
|
||||
? "bg-gradient-to-r from-amber-400/50 to-orange-300/50 dark:from-amber-500/30 dark:to-orange-600/30 hover:from-amber-400/60 hover:to-orange-300/60"
|
||||
: "bg-gradient-to-r from-slate-400/50 to-slate-200/50 dark:from-slate-400/30 dark:to-slate-800/60 hover:from-slate-400/60 hover:to-slate-200/60"
|
||||
)}
|
||||
>
|
||||
<div className="w-full flex items-center justify-between">
|
||||
{podcastIsStale ? (
|
||||
<RefreshCw strokeWidth={1} className="h-5 w-5" />
|
||||
) : (
|
||||
<Podcast strokeWidth={1} className="h-5 w-5" />
|
||||
)}
|
||||
<ConfigModal generatePodcast={generatePodcast} />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-left">
|
||||
{podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
title={podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setChatUIState((prev) => ({
|
||||
...prev,
|
||||
isChatPannelOpen: !isChatPannelOpen,
|
||||
}))
|
||||
}
|
||||
className={cn(
|
||||
"p-2 rounded-full hover:bg-muted transition-colors",
|
||||
podcastIsStale && "text-amber-600 dark:text-amber-500"
|
||||
)}
|
||||
>
|
||||
{podcastIsStale ? (
|
||||
<RefreshCw strokeWidth={1} className="h-5 w-5" />
|
||||
) : (
|
||||
<Podcast strokeWidth={1} className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{podcast ? (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full border-b",
|
||||
!isChatPannelOpen && "flex items-center justify-center p-4"
|
||||
)}
|
||||
>
|
||||
{isChatPannelOpen ? (
|
||||
<PodcastPlayer compact podcast={podcast} />
|
||||
) : podcast ? (
|
||||
<button
|
||||
title="Play Podcast"
|
||||
type="button"
|
||||
onClick={() => setChatUIState((prev) => ({ ...prev, isChatPannelOpen: true }))}
|
||||
className="p-2 rounded-full hover:bg-muted transition-colors text-green-600 dark:text-green-500"
|
||||
>
|
||||
<Play strokeWidth={1} className="h-5 w-5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
surfsense_web/components/chat/ChatPanel/ConfigModal.tsx
Normal file
72
surfsense_web/components/chat/ChatPanel/ConfigModal.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useCallback, useContext, useState } from "react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { activeChatAtom } from "@/stores/chat/active-chat.atom";
|
||||
import type { GeneratePodcastRequest } from "./ChatPanelContainer";
|
||||
|
||||
interface ConfigModalProps {
|
||||
generatePodcast: (request: GeneratePodcastRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ConfigModal(props: ConfigModalProps) {
|
||||
const { data: activeChatState } = useAtomValue(activeChatAtom);
|
||||
|
||||
const chatDetails = activeChatState?.chatDetails;
|
||||
const podcast = activeChatState?.podcast;
|
||||
|
||||
const { generatePodcast } = props;
|
||||
|
||||
const [userPromt, setUserPrompt] = useState("");
|
||||
|
||||
const handleGeneratePost = useCallback(async () => {
|
||||
if (!chatDetails) return;
|
||||
await generatePodcast({
|
||||
type: "CHAT",
|
||||
ids: [chatDetails.id],
|
||||
search_space_id: chatDetails.search_space_id,
|
||||
podcast_title: podcast?.title || chatDetails.title,
|
||||
user_prompt: userPromt,
|
||||
});
|
||||
}, [chatDetails, userPromt]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
title="Edit the prompt"
|
||||
className="rounded-full p-2 bg-slate-400/30 hover:bg-slate-400/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Pencil strokeWidth={1} className="h-4 w-4" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent onClick={(e) => e.stopPropagation()} align="end" className="bg-sidebar w-96 ">
|
||||
<form className="flex flex-col gap-3 w-full">
|
||||
<label className="text-sm font-medium" htmlFor="prompt">
|
||||
What subjects should the AI cover in this podcast ?
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
name="prompt"
|
||||
id="prompt"
|
||||
defaultValue={userPromt}
|
||||
className="w-full rounded-md border border-slate-400/40 p-2"
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setUserPrompt(e.target.value);
|
||||
}}
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGeneratePost}
|
||||
className="w-full rounded-md bg-foreground text-white dark:text-black p-2"
|
||||
>
|
||||
Generate Podcast
|
||||
</button>
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
"use client";
|
||||
|
||||
import { Pause, Play, Podcast, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton";
|
||||
|
||||
interface PodcastPlayerProps {
|
||||
podcast: PodcastItem | null;
|
||||
isLoading?: boolean;
|
||||
onClose?: () => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function PodcastPlayer({
|
||||
podcast,
|
||||
isLoading = false,
|
||||
onClose,
|
||||
compact = false,
|
||||
}: PodcastPlayerProps) {
|
||||
const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(0.7);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const currentObjectUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Cleanup object URL on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (currentObjectUrlRef.current) {
|
||||
URL.revokeObjectURL(currentObjectUrlRef.current);
|
||||
currentObjectUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load podcast audio when podcast changes
|
||||
useEffect(() => {
|
||||
if (!podcast) {
|
||||
setAudioSrc(undefined);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsPlaying(false);
|
||||
setIsFetching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadPodcast = async () => {
|
||||
setIsFetching(true);
|
||||
try {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
if (!token) {
|
||||
throw new Error("Authentication token not found.");
|
||||
}
|
||||
|
||||
// Revoke previous object URL if exists
|
||||
if (currentObjectUrlRef.current) {
|
||||
URL.revokeObjectURL(currentObjectUrlRef.current);
|
||||
currentObjectUrlRef.current = null;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch audio stream: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
currentObjectUrlRef.current = objectUrl;
|
||||
setAudioSrc(objectUrl);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw new Error("Request timed out. Please try again.");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching podcast:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to load podcast audio.");
|
||||
setAudioSrc(undefined);
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPodcast();
|
||||
}, [podcast]);
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (audioRef.current) {
|
||||
setCurrentTime(audioRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMetadataLoaded = () => {
|
||||
if (audioRef.current) {
|
||||
setDuration(audioRef.current.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlayPause = () => {
|
||||
if (audioRef.current) {
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
audioRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (value: number[]) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = value[0];
|
||||
setCurrentTime(value[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = (value: number[]) => {
|
||||
if (audioRef.current) {
|
||||
const newVolume = value[0];
|
||||
audioRef.current.volume = newVolume;
|
||||
setVolume(newVolume);
|
||||
|
||||
if (newVolume === 0) {
|
||||
audioRef.current.muted = true;
|
||||
setIsMuted(true);
|
||||
} else {
|
||||
audioRef.current.muted = false;
|
||||
setIsMuted(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (audioRef.current) {
|
||||
const newMutedState = !isMuted;
|
||||
audioRef.current.muted = newMutedState;
|
||||
setIsMuted(newMutedState);
|
||||
|
||||
if (!newMutedState && volume === 0) {
|
||||
const restoredVolume = 0.5;
|
||||
audioRef.current.volume = restoredVolume;
|
||||
setVolume(restoredVolume);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const skipForward = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = Math.min(
|
||||
audioRef.current.duration,
|
||||
audioRef.current.currentTime + 10
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const skipBackward = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
|
||||
};
|
||||
|
||||
// Show skeleton while fetching
|
||||
if (isFetching && compact) {
|
||||
return <PodcastPlayerCompactSkeleton />;
|
||||
}
|
||||
|
||||
if (!podcast || !audioSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.div
|
||||
className="w-8 h-8 bg-primary/20 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
animate={{ scale: isPlaying ? [1, 1.05, 1] : 1 }}
|
||||
transition={{
|
||||
repeat: isPlaying ? Infinity : 0,
|
||||
duration: 2,
|
||||
}}
|
||||
>
|
||||
<Podcast className="h-4 w-4 text-primary" />
|
||||
</motion.div>
|
||||
<h4 className="font-medium text-xs line-clamp-1 flex-grow">{podcast.title}</h4>
|
||||
{onClose && (
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-6 w-6 flex-shrink-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
min={0}
|
||||
max={duration || 100}
|
||||
step={0.1}
|
||||
onValueChange={handleSeek}
|
||||
className="flex-grow"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground whitespace-nowrap flex-shrink-0">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={skipBackward}
|
||||
className="h-7 w-7"
|
||||
disabled={!duration}
|
||||
>
|
||||
<SkipBack className="h-3 w-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={togglePlayPause}
|
||||
className="h-8 w-8 rounded-full"
|
||||
disabled={!duration}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={skipForward}
|
||||
className="h-7 w-7"
|
||||
disabled={!duration}
|
||||
>
|
||||
<SkipForward className="h-3 w-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleMute}
|
||||
className={`h-7 w-7 ${isMuted ? "text-muted-foreground" : "text-primary"}`}
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-3 w-3" /> : <Volume2 className="h-3 w-3" />}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioSrc}
|
||||
preload="auto"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleMetadataLoaded}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
onError={(e) => {
|
||||
console.error("Audio error:", e);
|
||||
if (audioRef.current?.error) {
|
||||
console.error("Audio error code:", audioRef.current.error.code);
|
||||
if (audioRef.current.error.code !== audioRef.current.error.MEDIA_ERR_ABORTED) {
|
||||
toast.error("Error playing audio. Please try again.");
|
||||
}
|
||||
}
|
||||
setIsPlaying(false);
|
||||
}}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import { Podcast } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
export function PodcastPlayerCompactSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{/* Header with icon and title */}
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.div
|
||||
className="w-8 h-8 bg-primary/20 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
animate={{ scale: [1, 1.05, 1] }}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 2,
|
||||
}}
|
||||
>
|
||||
<Podcast className="h-4 w-4 text-primary" />
|
||||
</motion.div>
|
||||
{/* Title skeleton */}
|
||||
<div className="h-4 bg-muted rounded w-32 flex-grow animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Progress bar skeleton */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-1 bg-muted rounded flex-grow animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-12 animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Controls skeleton */}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="h-7 w-7 bg-muted rounded-full animate-pulse" />
|
||||
<div className="h-8 w-8 bg-primary/20 rounded-full animate-pulse" />
|
||||
<div className="h-7 w-7 bg-muted rounded-full animate-pulse" />
|
||||
<div className="h-7 w-7 bg-muted rounded-full animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { PodcastPlayer } from "./PodcastPlayer";
|
||||
export { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton";
|
||||
43
surfsense_web/components/chat/PodcastUtils.ts
Normal file
43
surfsense_web/components/chat/PodcastUtils.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Determines if a podcast is stale compared to the current chat state.
|
||||
* A podcast is considered stale if:
|
||||
* - The chat's current state_version is greater than the podcast's chat_state_version
|
||||
*
|
||||
* @param chatVersion - The current state_version of the chat
|
||||
* @param podcastVersion - The chat_state_version stored when the podcast was generated (nullable)
|
||||
* @returns true if the podcast is stale, false otherwise
|
||||
*/
|
||||
export function isPodcastStale(
|
||||
chatVersion: number,
|
||||
podcastVersion: number | null | undefined
|
||||
): boolean {
|
||||
// If podcast has no version, it's stale (generated before this feature)
|
||||
if (!podcastVersion) {
|
||||
return true;
|
||||
}
|
||||
// If chat version is greater than podcast version, it's stale : We can change this condition to consider staleness after a huge number of updates
|
||||
return chatVersion > podcastVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a human-readable message about podcast staleness
|
||||
*
|
||||
* @param chatVersion - The current state_version of the chat
|
||||
* @param podcastVersion - The chat_state_version stored when the podcast was generated
|
||||
* @returns A descriptive message about the podcast's staleness status
|
||||
*/
|
||||
export function getPodcastStalenessMessage(
|
||||
chatVersion: number,
|
||||
podcastVersion: number | null | undefined
|
||||
): string {
|
||||
if (!podcastVersion) {
|
||||
return "This podcast was generated before chat updates were tracked. Consider regenerating it.";
|
||||
}
|
||||
|
||||
if (chatVersion > podcastVersion) {
|
||||
const versionDiff = chatVersion - podcastVersion;
|
||||
return `This podcast is outdated. The chat has been updated ${versionDiff} time${versionDiff > 1 ? "s" : ""} since this podcast was generated.`;
|
||||
}
|
||||
|
||||
return "This podcast is up to date with the current chat.";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue