mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-30 03:16:25 +02:00
feat: added attachment support
This commit is contained in:
parent
bb971460fc
commit
c2dcb2045d
62 changed files with 1166 additions and 9012 deletions
|
|
@ -7,7 +7,7 @@ import {
|
|||
useAssistantApi,
|
||||
useAssistantState,
|
||||
} from "@assistant-ui/react";
|
||||
import { FileText, PlusIcon, XIcon } from "lucide-react";
|
||||
import { FileText, Loader2, PlusIcon, XIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { type FC, type PropsWithChildren, useEffect, useState } from "react";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
|
|
@ -40,11 +40,15 @@ const useFileSrc = (file: File | undefined) => {
|
|||
const useAttachmentSrc = () => {
|
||||
const { file, src } = useAssistantState(
|
||||
useShallow(({ attachment }): { file?: File; src?: string } => {
|
||||
if (attachment.type !== "image") return {};
|
||||
if (!attachment || attachment.type !== "image") return {};
|
||||
if (attachment.file) return { file: attachment.file };
|
||||
const src = attachment.content?.filter((c) => c.type === "image")[0]?.image;
|
||||
if (!src) return {};
|
||||
return { src };
|
||||
// Only try to filter if content is an array (standard assistant-ui format)
|
||||
// Our custom ChatAttachment has content as a string, so skip this
|
||||
if (Array.isArray(attachment.content)) {
|
||||
const src = attachment.content.filter((c) => c.type === "image")[0]?.image;
|
||||
if (src) return { src };
|
||||
}
|
||||
return {};
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -98,9 +102,27 @@ const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => {
|
|||
};
|
||||
|
||||
const AttachmentThumb: FC = () => {
|
||||
const isImage = useAssistantState(({ attachment }) => attachment.type === "image");
|
||||
const isImage = useAssistantState(({ attachment }) => attachment?.type === "image");
|
||||
// Check if actively processing (running AND progress < 100)
|
||||
// When progress is 100, processing is done but waiting for send()
|
||||
const isProcessing = useAssistantState(({ attachment }) => {
|
||||
const status = attachment?.status;
|
||||
if (status?.type !== "running") return false;
|
||||
// If progress is defined and equals 100, processing is complete
|
||||
const progress = (status as { type: "running"; progress?: number }).progress;
|
||||
return progress === undefined || progress < 100;
|
||||
});
|
||||
const src = useAttachmentSrc();
|
||||
|
||||
// Show loading spinner only when actively processing (not when done and waiting for send)
|
||||
if (isProcessing) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none">
|
||||
<AvatarImage
|
||||
|
|
@ -119,9 +141,17 @@ const AttachmentUI: FC = () => {
|
|||
const api = useAssistantApi();
|
||||
const isComposer = api.attachment.source === "composer";
|
||||
|
||||
const isImage = useAssistantState(({ attachment }) => attachment.type === "image");
|
||||
const isImage = useAssistantState(({ attachment }) => attachment?.type === "image");
|
||||
// Check if actively processing (running AND progress < 100)
|
||||
// When progress is 100, processing is done but waiting for send()
|
||||
const isProcessing = useAssistantState(({ attachment }) => {
|
||||
const status = attachment?.status;
|
||||
if (status?.type !== "running") return false;
|
||||
const progress = (status as { type: "running"; progress?: number }).progress;
|
||||
return progress === undefined || progress < 100;
|
||||
});
|
||||
const typeLabel = useAssistantState(({ attachment }) => {
|
||||
const type = attachment.type;
|
||||
const type = attachment?.type;
|
||||
switch (type) {
|
||||
case "image":
|
||||
return "Image";
|
||||
|
|
@ -129,10 +159,8 @@ const AttachmentUI: FC = () => {
|
|||
return "Document";
|
||||
case "file":
|
||||
return "File";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = type;
|
||||
throw new Error(`Unknown attachment type: ${_exhaustiveCheck}`);
|
||||
}
|
||||
default:
|
||||
return "File"; // Default fallback for unknown types
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -149,20 +177,28 @@ const AttachmentUI: FC = () => {
|
|||
<div
|
||||
className={cn(
|
||||
"aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[14px] border bg-muted transition-opacity hover:opacity-75",
|
||||
isComposer && "aui-attachment-tile-composer border-foreground/20"
|
||||
isComposer && "aui-attachment-tile-composer border-foreground/20",
|
||||
isProcessing && "animate-pulse"
|
||||
)}
|
||||
role="button"
|
||||
id="attachment-tile"
|
||||
aria-label={`${typeLabel} attachment`}
|
||||
aria-label={isProcessing ? "Processing attachment..." : `${typeLabel} attachment`}
|
||||
>
|
||||
<AttachmentThumb />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</AttachmentPreviewDialog>
|
||||
{isComposer && <AttachmentRemove />}
|
||||
{isComposer && !isProcessing && <AttachmentRemove />}
|
||||
</AttachmentPrimitive.Root>
|
||||
<TooltipContent side="top">
|
||||
<AttachmentPrimitive.Name />
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Processing...
|
||||
</span>
|
||||
) : (
|
||||
<AttachmentPrimitive.Name />
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
ErrorPrimitive,
|
||||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
useAssistantState,
|
||||
} from "@assistant-ui/react";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
|
|
@ -15,6 +16,7 @@ import {
|
|||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
SquareIcon,
|
||||
|
|
@ -157,20 +159,43 @@ const Composer: FC = () => {
|
|||
};
|
||||
|
||||
const ComposerAction: FC = () => {
|
||||
// Check if any attachments are still being processed (running AND progress < 100)
|
||||
// When progress is 100, processing is done but waiting for send()
|
||||
const hasProcessingAttachments = useAssistantState(({ composer }) =>
|
||||
composer.attachments?.some((att) => {
|
||||
const status = att.status;
|
||||
if (status?.type !== "running") return false;
|
||||
const progress = (status as { type: "running"; progress?: number }).progress;
|
||||
return progress === undefined || progress < 100;
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||
<ComposerAddAttachment />
|
||||
|
||||
{/* Show processing indicator when attachments are being processed */}
|
||||
{hasProcessingAttachments && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<span>Processing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<ComposerPrimitive.Send asChild disabled={hasProcessingAttachments}>
|
||||
<TooltipIconButton
|
||||
tooltip="Send message"
|
||||
tooltip={hasProcessingAttachments ? "Wait for attachments to process" : "Send message"}
|
||||
side="bottom"
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="aui-composer-send size-8 rounded-full"
|
||||
className={cn(
|
||||
"aui-composer-send size-8 rounded-full",
|
||||
hasProcessingAttachments && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
aria-label="Send message"
|
||||
disabled={hasProcessingAttachments}
|
||||
>
|
||||
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
||||
</TooltipIconButton>
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useInView } from "motion/react";
|
||||
import { Manrope } from "next/font/google";
|
||||
import { useEffect, useMemo, useReducer, useRef } from "react";
|
||||
import { RoughNotation, RoughNotationGroup } from "react-rough-notation";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Font configuration - could be moved to a global font config file
|
||||
const manrope = Manrope({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "700"],
|
||||
display: "swap", // Optimize font loading
|
||||
variable: "--font-manrope",
|
||||
});
|
||||
|
||||
// Constants for timing - makes it easier to adjust and more maintainable
|
||||
const TIMING = {
|
||||
SIDEBAR_TRANSITION: 300, // Wait for sidebar transition + buffer
|
||||
LAYOUT_SETTLE: 100, // Small delay to ensure layout is fully settled
|
||||
} as const;
|
||||
|
||||
// Animation configuration
|
||||
const ANIMATION_CONFIG = {
|
||||
HIGHLIGHT: {
|
||||
type: "highlight" as const,
|
||||
animationDuration: 2000,
|
||||
iterations: 3,
|
||||
color: "#3b82f680",
|
||||
multiline: true,
|
||||
},
|
||||
UNDERLINE: {
|
||||
type: "underline" as const,
|
||||
animationDuration: 2000,
|
||||
iterations: 3,
|
||||
color: "#10b981",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// State management with useReducer for better organization
|
||||
interface HighlightState {
|
||||
shouldShowHighlight: boolean;
|
||||
layoutStable: boolean;
|
||||
}
|
||||
|
||||
type HighlightAction =
|
||||
| { type: "SIDEBAR_CHANGED" }
|
||||
| { type: "LAYOUT_STABILIZED" }
|
||||
| { type: "SHOW_HIGHLIGHT" }
|
||||
| { type: "HIDE_HIGHLIGHT" };
|
||||
|
||||
const highlightReducer = (state: HighlightState, action: HighlightAction): HighlightState => {
|
||||
switch (action.type) {
|
||||
case "SIDEBAR_CHANGED":
|
||||
return {
|
||||
shouldShowHighlight: false,
|
||||
layoutStable: false,
|
||||
};
|
||||
case "LAYOUT_STABILIZED":
|
||||
return {
|
||||
...state,
|
||||
layoutStable: true,
|
||||
};
|
||||
case "SHOW_HIGHLIGHT":
|
||||
return {
|
||||
...state,
|
||||
shouldShowHighlight: true,
|
||||
};
|
||||
case "HIDE_HIGHLIGHT":
|
||||
return {
|
||||
...state,
|
||||
shouldShowHighlight: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const initialState: HighlightState = {
|
||||
shouldShowHighlight: false,
|
||||
layoutStable: true,
|
||||
};
|
||||
|
||||
export function AnimatedEmptyState() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref);
|
||||
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
|
||||
highlightReducer,
|
||||
initialState
|
||||
);
|
||||
|
||||
// Memoize class names to prevent unnecessary recalculations
|
||||
const headingClassName = useMemo(
|
||||
() =>
|
||||
cn(
|
||||
"text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-neutral-900 dark:text-neutral-50 mb-6",
|
||||
manrope.className
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const paragraphClassName = useMemo(
|
||||
() => "text-lg sm:text-xl text-neutral-600 dark:text-neutral-300 mb-8 max-w-2xl mx-auto",
|
||||
[]
|
||||
);
|
||||
|
||||
// Handle sidebar state changes
|
||||
useEffect(() => {
|
||||
dispatch({ type: "SIDEBAR_CHANGED" });
|
||||
|
||||
const stabilizeTimer = setTimeout(() => {
|
||||
dispatch({ type: "LAYOUT_STABILIZED" });
|
||||
}, TIMING.SIDEBAR_TRANSITION);
|
||||
|
||||
return () => clearTimeout(stabilizeTimer);
|
||||
}, []);
|
||||
|
||||
// Handle highlight visibility based on layout stability and viewport visibility
|
||||
useEffect(() => {
|
||||
if (!layoutStable || !isInView) {
|
||||
dispatch({ type: "HIDE_HIGHLIGHT" });
|
||||
return;
|
||||
}
|
||||
|
||||
const showTimer = setTimeout(() => {
|
||||
dispatch({ type: "SHOW_HIGHLIGHT" });
|
||||
}, TIMING.LAYOUT_SETTLE);
|
||||
|
||||
return () => clearTimeout(showTimer);
|
||||
}, [layoutStable, isInView]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex-1 flex items-center justify-center w-full min-h-fit">
|
||||
<div className="max-w-4xl mx-auto px-4 py-10 text-center">
|
||||
<RoughNotationGroup show={shouldShowHighlight}>
|
||||
<h1 className={headingClassName}>
|
||||
<RoughNotation {...ANIMATION_CONFIG.HIGHLIGHT}>
|
||||
<span>SurfSense</span>
|
||||
</RoughNotation>
|
||||
</h1>
|
||||
|
||||
<p className={paragraphClassName}>
|
||||
<RoughNotation {...ANIMATION_CONFIG.UNDERLINE}>Let's Start Surfing</RoughNotation>{" "}
|
||||
through your knowledge base.
|
||||
</p>
|
||||
</RoughNotationGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { SheetTrigger } from "@/components/ui/sheet";
|
||||
import { SourceDetailSheet } from "./SourceDetailSheet";
|
||||
|
||||
export const CitationDisplay: React.FC<{ index: number; node: any }> = ({ index, node }) => {
|
||||
const chunkId = Number(node?.id);
|
||||
const sourceType = node?.metadata?.source_type;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<SourceDetailSheet
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
chunkId={chunkId}
|
||||
sourceType={sourceType}
|
||||
title={node?.metadata?.title || node?.metadata?.group_name || "Source"}
|
||||
description={node?.text}
|
||||
url={node?.url}
|
||||
>
|
||||
<SheetTrigger asChild>
|
||||
<span className="text-[10px] font-bold bg-slate-500 hover:bg-slate-600 text-white rounded-full w-4 h-4 inline-flex items-center justify-center align-super cursor-pointer transition-colors">
|
||||
{index + 1}
|
||||
</span>
|
||||
</SheetTrigger>
|
||||
</SourceDetailSheet>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { getAnnotationData, type Message, useChatUI } from "@llamaindex/chat-ui";
|
||||
import { SuggestedQuestions } from "@llamaindex/chat-ui/widgets";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
|
||||
export const ChatFurtherQuestions: React.FC<{ message: Message }> = ({ message }) => {
|
||||
const annotations: string[][] = getAnnotationData(message, "FURTHER_QUESTIONS");
|
||||
const { append, requestData } = useChatUI();
|
||||
|
||||
if (annotations.length !== 1 || annotations[0].length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion type="single" collapsible className="w-full border rounded-md bg-card shadow-sm">
|
||||
<AccordionItem value="suggested-questions" className="border-0">
|
||||
<AccordionTrigger className="px-4 py-3 text-sm font-medium text-foreground transition-colors">
|
||||
Further Suggested Questions
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4 pt-0">
|
||||
<SuggestedQuestions
|
||||
questions={annotations[0]}
|
||||
append={append}
|
||||
requestData={requestData}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,851 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ChatInput } from "@llamaindex/chat-ui";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { Brain, Check, FolderOpen, Minus, Plus, PlusCircle, Zap } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import React, { Suspense, useCallback, useMemo, useState } from "react";
|
||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/llm-config/llm-config-mutation.atoms";
|
||||
import {
|
||||
globalLLMConfigsAtom,
|
||||
llmConfigsAtom,
|
||||
llmPreferencesAtom,
|
||||
} from "@/atoms/llm-config/llm-config-query.atoms";
|
||||
import { DocumentsDataTable } from "@/components/chat/DocumentsDataTable";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
const DocumentSelector = React.memo(
|
||||
({
|
||||
onSelectionChange,
|
||||
selectedDocuments = [],
|
||||
}: {
|
||||
onSelectionChange?: (documents: Document[]) => void;
|
||||
selectedDocuments?: Document[];
|
||||
}) => {
|
||||
const { search_space_id } = useParams();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
setIsOpen(open);
|
||||
}, []);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(documents: Document[]) => {
|
||||
onSelectionChange?.(documents);
|
||||
},
|
||||
[onSelectionChange]
|
||||
);
|
||||
|
||||
const handleDone = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const selectedCount = React.useMemo(() => selectedDocuments.length, [selectedDocuments.length]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 gap-2 px-3 border-dashed hover:border-solid hover:bg-accent/50 transition-all"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">
|
||||
{selectedCount > 0 ? `Selected` : "Documents"}
|
||||
</span>
|
||||
{selectedCount > 0 && (
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-xs font-medium">
|
||||
{selectedCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-[95vw] md:max-w-5xl h-[90vh] md:h-[85vh] p-0 flex flex-col">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-4 md:px-6 py-4 border-b flex-shrink-0 bg-muted/30">
|
||||
<DialogTitle className="text-lg md:text-xl font-semibold">
|
||||
Select Documents
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1.5 text-sm">
|
||||
Choose specific documents to include in your research context
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 p-4 md:p-6">
|
||||
<DocumentsDataTable
|
||||
searchSpaceId={Number(search_space_id)}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onDone={handleDone}
|
||||
initialSelectedDocuments={selectedDocuments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DocumentSelector.displayName = "DocumentSelector";
|
||||
|
||||
const ConnectorSelector = React.memo(
|
||||
({
|
||||
onSelectionChange,
|
||||
selectedConnectors = [],
|
||||
}: {
|
||||
onSelectionChange?: (connectorTypes: string[]) => void;
|
||||
selectedConnectors?: string[];
|
||||
}) => {
|
||||
const { search_space_id } = useParams();
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Use the documentTypeCountsAtom for fetching document types
|
||||
const [documentTypeCountsQuery] = useAtom(documentTypeCountsAtom);
|
||||
const {
|
||||
data: documentTypeCountsData,
|
||||
isLoading,
|
||||
refetch: fetchDocumentTypes,
|
||||
} = documentTypeCountsQuery;
|
||||
|
||||
// Transform the response into the expected format
|
||||
const documentTypes = useMemo(() => {
|
||||
if (!documentTypeCountsData) return [];
|
||||
return Object.entries(documentTypeCountsData).map(([type, count]) => ({
|
||||
type,
|
||||
count,
|
||||
}));
|
||||
}, [documentTypeCountsData]);
|
||||
|
||||
const isLoaded = !!documentTypeCountsData;
|
||||
|
||||
// Fetch live search connectors immediately (non-indexable)
|
||||
const {
|
||||
connectors: searchConnectors,
|
||||
isLoading: connectorsLoading,
|
||||
isLoaded: connectorsLoaded,
|
||||
fetchConnectors,
|
||||
} = useSearchSourceConnectors(false, Number(search_space_id));
|
||||
|
||||
// Filter for non-indexable connectors (live search)
|
||||
const liveSearchConnectors = React.useMemo(
|
||||
() => searchConnectors.filter((connector) => !connector.is_indexable),
|
||||
[searchConnectors]
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
setIsOpen(open);
|
||||
// Data is already loaded on mount, no need to fetch again
|
||||
}, []);
|
||||
|
||||
const handleConnectorToggle = useCallback(
|
||||
(connectorType: string) => {
|
||||
const isSelected = selectedConnectors.includes(connectorType);
|
||||
const newSelection = isSelected
|
||||
? selectedConnectors.filter((type) => type !== connectorType)
|
||||
: [...selectedConnectors, connectorType];
|
||||
onSelectionChange?.(newSelection);
|
||||
},
|
||||
[selectedConnectors, onSelectionChange]
|
||||
);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const allTypes = [
|
||||
...documentTypes.map((dt) => dt.type),
|
||||
...liveSearchConnectors.map((c) => c.connector_type),
|
||||
];
|
||||
onSelectionChange?.(allTypes);
|
||||
}, [documentTypes, liveSearchConnectors, onSelectionChange]);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
onSelectionChange?.([]);
|
||||
}, [onSelectionChange]);
|
||||
|
||||
// Get display name for connector type
|
||||
const getDisplayName = (type: string) => {
|
||||
return type
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
// Get selected document types with their counts
|
||||
const selectedDocTypes = documentTypes.filter((dt) => selectedConnectors.includes(dt.type));
|
||||
const selectedLiveConnectors = liveSearchConnectors.filter((c) =>
|
||||
selectedConnectors.includes(c.connector_type)
|
||||
);
|
||||
|
||||
// Total selected count
|
||||
const totalSelectedCount = selectedDocTypes.length + selectedLiveConnectors.length;
|
||||
const totalAvailableCount = documentTypes.length + liveSearchConnectors.length;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative h-9 gap-2 px-3 border-dashed hover:border-solid hover:bg-accent/50 transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{totalSelectedCount > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center -space-x-2">
|
||||
{selectedDocTypes.slice(0, 2).map((docType) => (
|
||||
<div
|
||||
key={docType.type}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-background bg-muted"
|
||||
>
|
||||
{getConnectorIcon(docType.type, "h-3 w-3")}
|
||||
</div>
|
||||
))}
|
||||
{selectedLiveConnectors
|
||||
.slice(0, 3 - selectedDocTypes.slice(0, 2).length)
|
||||
.map((connector) => (
|
||||
<div
|
||||
key={connector.id}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-background bg-muted"
|
||||
>
|
||||
{getConnectorIcon(connector.connector_type, "h-3 w-3")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs font-medium">
|
||||
{totalSelectedCount} {totalSelectedCount === 1 ? "source" : "sources"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Brain className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Sources</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<div className="space-y-4 flex-1 overflow-y-auto pr-2">
|
||||
<div>
|
||||
<DialogTitle className="text-xl">Select Sources</DialogTitle>
|
||||
<DialogDescription className="mt-1.5">
|
||||
Choose indexed document types and live search connectors to include in your search
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
{isLoading || connectorsLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-3 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : totalAvailableCount === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="rounded-full bg-muted p-4 mb-4">
|
||||
<Brain className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="text-sm font-medium mb-1">No sources found</h4>
|
||||
<p className="text-xs text-muted-foreground max-w-xs mb-4">
|
||||
Add documents or configure search connectors for this search space
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
router.push(`/dashboard/${search_space_id}/sources/add`);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
Add Sources
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Live Search Connectors Section */}
|
||||
{liveSearchConnectors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">Live Search Connectors</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Real-time
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{liveSearchConnectors.map((connector) => {
|
||||
const isSelected = selectedConnectors.includes(connector.connector_type);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={connector.id}
|
||||
onClick={() => handleConnectorToggle(connector.connector_type)}
|
||||
type="button"
|
||||
className={`group relative flex items-center gap-3 p-3 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-border hover:border-primary/50 hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-md transition-colors ${
|
||||
isSelected ? "bg-primary/10" : "bg-muted group-hover:bg-primary/5"
|
||||
}`}
|
||||
>
|
||||
{getConnectorIcon(
|
||||
connector.connector_type,
|
||||
`h-5 w-5 ${isSelected ? "text-primary" : "text-muted-foreground group-hover:text-primary"}`
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">{connector.name}</p>
|
||||
{isSelected && (
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-primary">
|
||||
<Check className="h-3 w-3 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{getDisplayName(connector.connector_type)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document Types Section */}
|
||||
{documentTypes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<FolderOpen className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">Indexed Document Types</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Stored
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{documentTypes.map((docType) => {
|
||||
const isSelected = selectedConnectors.includes(docType.type);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={docType.type}
|
||||
onClick={() => handleConnectorToggle(docType.type)}
|
||||
type="button"
|
||||
className={`group relative flex items-center gap-3 p-3 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-border hover:border-primary/50 hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-md transition-colors ${
|
||||
isSelected ? "bg-primary/10" : "bg-muted group-hover:bg-primary/5"
|
||||
}`}
|
||||
>
|
||||
{getConnectorIcon(
|
||||
docType.type,
|
||||
`h-5 w-5 ${isSelected ? "text-primary" : "text-muted-foreground group-hover:text-primary"}`
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{getDisplayName(docType.type)}
|
||||
</p>
|
||||
{isSelected && (
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-primary">
|
||||
<Check className="h-3 w-3 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{docType.count} {docType.count === 1 ? "document" : "documents"}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalAvailableCount > 0 && (
|
||||
<DialogFooter className="flex flex-row justify-between items-center gap-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
disabled={selectedConnectors.length === 0}
|
||||
className="text-xs"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
disabled={selectedConnectors.length === totalAvailableCount}
|
||||
className="text-xs"
|
||||
>
|
||||
Select All ({totalAvailableCount})
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ConnectorSelector.displayName = "ConnectorSelector";
|
||||
|
||||
const TopKSelector = React.memo(
|
||||
({ topK = 10, onTopKChange }: { topK?: number; onTopKChange?: (topK: number) => void }) => {
|
||||
const MIN_VALUE = 1;
|
||||
const MAX_VALUE = 100;
|
||||
|
||||
const handleIncrement = React.useCallback(() => {
|
||||
if (topK < MAX_VALUE) {
|
||||
onTopKChange?.(topK + 1);
|
||||
}
|
||||
}, [topK, onTopKChange]);
|
||||
|
||||
const handleDecrement = React.useCallback(() => {
|
||||
if (topK > MIN_VALUE) {
|
||||
onTopKChange?.(topK - 1);
|
||||
}
|
||||
}, [topK, onTopKChange]);
|
||||
|
||||
const handleInputChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
// Allow empty input for editing
|
||||
if (value === "") {
|
||||
return;
|
||||
}
|
||||
const numValue = parseInt(value, 10);
|
||||
if (!isNaN(numValue) && numValue >= MIN_VALUE && numValue <= MAX_VALUE) {
|
||||
onTopKChange?.(numValue);
|
||||
}
|
||||
},
|
||||
[onTopKChange]
|
||||
);
|
||||
|
||||
const handleInputBlur = React.useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
// Reset to default if empty
|
||||
onTopKChange?.(10);
|
||||
return;
|
||||
}
|
||||
const numValue = parseInt(value, 10);
|
||||
if (isNaN(numValue) || numValue < MIN_VALUE) {
|
||||
onTopKChange?.(MIN_VALUE);
|
||||
} else if (numValue > MAX_VALUE) {
|
||||
onTopKChange?.(MAX_VALUE);
|
||||
}
|
||||
},
|
||||
[onTopKChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center h-8 border rounded-md bg-background hover:bg-accent/50 transition-colors">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-full w-7 rounded-l-md rounded-r-none hover:bg-accent border-r"
|
||||
onClick={handleDecrement}
|
||||
disabled={topK <= MIN_VALUE}
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div className="flex flex-col items-center justify-center px-2 min-w-[60px]">
|
||||
<Input
|
||||
type="number"
|
||||
value={topK}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
min={MIN_VALUE}
|
||||
max={MAX_VALUE}
|
||||
className="h-5 w-full px-1 text-center text-sm font-semibold border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground leading-none">Results</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-full w-7 rounded-r-md rounded-l-none hover:bg-accent border-l"
|
||||
onClick={handleIncrement}
|
||||
disabled={topK >= MAX_VALUE}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold">Results per Source</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Control how many results to fetch from each data source. Set a higher number to get
|
||||
more information, or a lower number for faster, more focused results.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground pt-1 border-t">
|
||||
<span>Recommended: 5-20</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Range: {MIN_VALUE}-{MAX_VALUE}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TopKSelector.displayName = "TopKSelector";
|
||||
|
||||
const LLMSelector = React.memo(() => {
|
||||
const { search_space_id } = useParams();
|
||||
const searchSpaceId = Number(search_space_id);
|
||||
|
||||
const {
|
||||
data: llmConfigs = [],
|
||||
isFetching: llmLoading,
|
||||
isError: error,
|
||||
} = useAtomValue(llmConfigsAtom);
|
||||
const {
|
||||
data: globalConfigs = [],
|
||||
isFetching: globalConfigsLoading,
|
||||
isError: globalConfigsError,
|
||||
} = useAtomValue(globalLLMConfigsAtom);
|
||||
|
||||
// Replace useLLMPreferences with jotai atoms
|
||||
const { data: preferences = {}, isFetching: preferencesLoading } =
|
||||
useAtomValue(llmPreferencesAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const isLoading = llmLoading || preferencesLoading || globalConfigsLoading;
|
||||
|
||||
// Combine global and custom configs
|
||||
const allConfigs = React.useMemo(() => {
|
||||
return [...globalConfigs.map((config) => ({ ...config, is_global: true })), ...llmConfigs];
|
||||
}, [globalConfigs, llmConfigs]);
|
||||
|
||||
// Memoize the selected config to avoid repeated lookups
|
||||
const selectedConfig = React.useMemo(() => {
|
||||
if (!preferences.fast_llm_id || !allConfigs.length) return null;
|
||||
return allConfigs.find((config) => config.id === preferences.fast_llm_id) || null;
|
||||
}, [preferences.fast_llm_id, allConfigs]);
|
||||
|
||||
// Memoize the display value for the trigger
|
||||
const displayValue = React.useMemo(() => {
|
||||
if (!selectedConfig) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-xs">{selectedConfig.provider}</span>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="hidden sm:inline text-muted-foreground text-xs truncate max-w-[60px]">
|
||||
{selectedConfig.name}
|
||||
</span>
|
||||
{"is_global" in selectedConfig && selectedConfig.is_global && (
|
||||
<span className="text-xs">🌐</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [selectedConfig]);
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(value: string) => {
|
||||
const llmId = value ? parseInt(value, 10) : undefined;
|
||||
updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { fast_llm_id: llmId },
|
||||
});
|
||||
},
|
||||
[updatePreferences, searchSpaceId]
|
||||
);
|
||||
|
||||
// Loading skeleton
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-8 min-w-[100px] sm:min-w-[120px]">
|
||||
<div className="h-8 rounded-md bg-muted animate-pulse flex items-center px-3">
|
||||
<div className="w-3 h-3 rounded bg-muted-foreground/20 mr-2" />
|
||||
<div className="h-3 w-16 rounded bg-muted-foreground/20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error || globalConfigsError) {
|
||||
return (
|
||||
<div className="h-8 min-w-[100px] sm:min-w-[120px]">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs text-destructive border-destructive/50 hover:bg-destructive/10"
|
||||
disabled
|
||||
>
|
||||
<span className="text-xs">Error</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-8 min-w-0">
|
||||
<Select
|
||||
value={preferences.fast_llm_id?.toString() || ""}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[100px] sm:min-w-[120px] px-3 text-xs border-border bg-background hover:bg-muted/50 transition-colors duration-200 focus:ring-2 focus:ring-primary/20">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Zap className="h-3 w-3 text-primary flex-shrink-0" />
|
||||
<SelectValue placeholder="Fast LLM" className="text-xs">
|
||||
{displayValue || <span className="text-muted-foreground">Select LLM</span>}
|
||||
</SelectValue>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent align="end" className="w-[300px] max-h-[400px]">
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-3 w-3" />
|
||||
Fast LLM Selection
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allConfigs.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center">
|
||||
<div className="mx-auto w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
|
||||
<Brain className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="text-sm font-medium mb-1">No LLM configurations</h4>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Configure AI models to get started
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => window.open("/settings", "_blank")}
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{/* Global Configurations */}
|
||||
{globalConfigs.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Global Configurations
|
||||
</div>
|
||||
{globalConfigs.map((config) => (
|
||||
<SelectItem
|
||||
key={config.id}
|
||||
value={config.id.toString()}
|
||||
className="px-3 py-2 cursor-pointer hover:bg-accent/50 focus:bg-accent"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 flex-shrink-0">
|
||||
<Brain className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="font-medium text-sm truncate">{config.name}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1.5 py-0.5 flex-shrink-0"
|
||||
>
|
||||
{config.provider}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs px-1.5 py-0.5 flex-shrink-0"
|
||||
>
|
||||
🌐 Global
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||
{config.model_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Custom Configurations */}
|
||||
{llmConfigs.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Your Configurations
|
||||
</div>
|
||||
{llmConfigs.map((config) => (
|
||||
<SelectItem
|
||||
key={config.id}
|
||||
value={config.id.toString()}
|
||||
className="px-3 py-2 cursor-pointer hover:bg-accent/50 focus:bg-accent"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 flex-shrink-0">
|
||||
<Brain className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm truncate">{config.name}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1.5 py-0.5 flex-shrink-0"
|
||||
>
|
||||
{config.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||
{config.model_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LLMSelector.displayName = "LLMSelector";
|
||||
|
||||
const CustomChatInputOptions = React.memo(
|
||||
({
|
||||
onDocumentSelectionChange,
|
||||
selectedDocuments,
|
||||
onConnectorSelectionChange,
|
||||
selectedConnectors,
|
||||
topK,
|
||||
onTopKChange,
|
||||
}: {
|
||||
onDocumentSelectionChange?: (documents: Document[]) => void;
|
||||
selectedDocuments?: Document[];
|
||||
onConnectorSelectionChange?: (connectorTypes: string[]) => void;
|
||||
selectedConnectors?: string[];
|
||||
topK?: number;
|
||||
onTopKChange?: (topK: number) => void;
|
||||
}) => {
|
||||
// Memoize the loading fallback to prevent recreation
|
||||
const loadingFallback = React.useMemo(
|
||||
() => <div className="h-9 w-24 animate-pulse bg-muted/50 rounded-md" />,
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Suspense fallback={loadingFallback}>
|
||||
<DocumentSelector
|
||||
onSelectionChange={onDocumentSelectionChange}
|
||||
selectedDocuments={selectedDocuments}
|
||||
/>
|
||||
</Suspense>
|
||||
<Suspense fallback={loadingFallback}>
|
||||
<ConnectorSelector
|
||||
onSelectionChange={onConnectorSelectionChange}
|
||||
selectedConnectors={selectedConnectors}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border hidden sm:block" />
|
||||
<TopKSelector topK={topK} onTopKChange={onTopKChange} />
|
||||
<div className="h-4 w-px bg-border hidden sm:block" />
|
||||
<LLMSelector />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CustomChatInputOptions.displayName = "CustomChatInputOptions";
|
||||
|
||||
export const ChatInputUI = React.memo(
|
||||
({
|
||||
onDocumentSelectionChange,
|
||||
selectedDocuments,
|
||||
onConnectorSelectionChange,
|
||||
selectedConnectors,
|
||||
topK,
|
||||
onTopKChange,
|
||||
}: {
|
||||
onDocumentSelectionChange?: (documents: Document[]) => void;
|
||||
selectedDocuments?: Document[];
|
||||
onConnectorSelectionChange?: (connectorTypes: string[]) => void;
|
||||
selectedConnectors?: string[];
|
||||
topK?: number;
|
||||
onTopKChange?: (topK: number) => void;
|
||||
}) => {
|
||||
return (
|
||||
<ChatInput className="p-2">
|
||||
<ChatInput.Form className="flex gap-2">
|
||||
<ChatInput.Field className="flex-1" />
|
||||
<ChatInput.Submit />
|
||||
</ChatInput.Form>
|
||||
<CustomChatInputOptions
|
||||
onDocumentSelectionChange={onDocumentSelectionChange}
|
||||
selectedDocuments={selectedDocuments}
|
||||
onConnectorSelectionChange={onConnectorSelectionChange}
|
||||
selectedConnectors={selectedConnectors}
|
||||
topK={topK}
|
||||
onTopKChange={onTopKChange}
|
||||
/>
|
||||
</ChatInput>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ChatInputUI.displayName = "ChatInputUI";
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { type ChatHandler, ChatSection as LlamaIndexChatSection } from "@llamaindex/chat-ui";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ChatInputUI } from "@/components/chat/ChatInputGroup";
|
||||
import { ChatMessagesUI } from "@/components/chat/ChatMessages";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
handler: ChatHandler;
|
||||
onDocumentSelectionChange?: (documents: Document[]) => void;
|
||||
selectedDocuments?: Document[];
|
||||
onConnectorSelectionChange?: (connectorTypes: string[]) => void;
|
||||
selectedConnectors?: string[];
|
||||
topK?: number;
|
||||
onTopKChange?: (topK: number) => void;
|
||||
}
|
||||
|
||||
export default function ChatInterface({
|
||||
handler,
|
||||
onDocumentSelectionChange,
|
||||
selectedDocuments = [],
|
||||
onConnectorSelectionChange,
|
||||
selectedConnectors = [],
|
||||
topK = 10,
|
||||
onTopKChange,
|
||||
}: ChatInterfaceProps) {
|
||||
const { chat_id, search_space_id } = useParams();
|
||||
|
||||
return (
|
||||
<LlamaIndexChatSection handler={handler} className="flex h-full max-w-7xl mx-auto">
|
||||
<div className="flex grow-1 flex-col">
|
||||
<ChatMessagesUI />
|
||||
<div className="border-1 rounded-4xl p-2">
|
||||
<ChatInputUI
|
||||
onDocumentSelectionChange={onDocumentSelectionChange}
|
||||
selectedDocuments={selectedDocuments}
|
||||
onConnectorSelectionChange={onConnectorSelectionChange}
|
||||
selectedConnectors={selectedConnectors}
|
||||
topK={topK}
|
||||
onTopKChange={onTopKChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</LlamaIndexChatSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ChatMessage as LlamaIndexChatMessage,
|
||||
ChatMessages as LlamaIndexChatMessages,
|
||||
type Message,
|
||||
useChatUI,
|
||||
} from "@llamaindex/chat-ui";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AnimatedEmptyState } from "@/components/chat/AnimatedEmptyState";
|
||||
import { CitationDisplay } from "@/components/chat/ChatCitation";
|
||||
import { ChatFurtherQuestions } from "@/components/chat/ChatFurtherQuestions";
|
||||
import ChatSourcesDisplay from "@/components/chat/ChatSources";
|
||||
import TerminalDisplay from "@/components/chat/ChatTerminal";
|
||||
import { languageRenderers } from "@/components/chat/CodeBlock";
|
||||
|
||||
export function ChatMessagesUI() {
|
||||
const { messages } = useChatUI();
|
||||
|
||||
return (
|
||||
<LlamaIndexChatMessages className="flex-1">
|
||||
<LlamaIndexChatMessages.Empty>
|
||||
<AnimatedEmptyState />
|
||||
</LlamaIndexChatMessages.Empty>
|
||||
<LlamaIndexChatMessages.List className="p-2">
|
||||
{messages.map((message, index) => (
|
||||
<ChatMessageUI
|
||||
key={`Message-${index}`}
|
||||
message={message}
|
||||
isLast={index === messages.length - 1}
|
||||
/>
|
||||
))}
|
||||
</LlamaIndexChatMessages.List>
|
||||
<LlamaIndexChatMessages.Loading />
|
||||
</LlamaIndexChatMessages>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessageUI({ message, isLast }: { message: Message; isLast: boolean }) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLast && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [isLast]);
|
||||
|
||||
return (
|
||||
<LlamaIndexChatMessage message={message} isLast={isLast} className="flex flex-col ">
|
||||
{message.role === "assistant" ? (
|
||||
<div className="flex-1 flex flex-col space-y-4">
|
||||
<TerminalDisplay message={message} open={isLast} />
|
||||
<ChatSourcesDisplay message={message} />
|
||||
<LlamaIndexChatMessage.Content className="flex-1">
|
||||
<LlamaIndexChatMessage.Content.Markdown
|
||||
citationComponent={CitationDisplay}
|
||||
languageRenderers={languageRenderers}
|
||||
/>
|
||||
</LlamaIndexChatMessage.Content>
|
||||
<div ref={bottomRef} />
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{isLast && <ChatFurtherQuestions message={message} />}
|
||||
<LlamaIndexChatMessage.Actions className="flex-1 flex-col" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<LlamaIndexChatMessage.Content className="flex-1">
|
||||
<LlamaIndexChatMessage.Content.Markdown languageRenderers={languageRenderers} />
|
||||
</LlamaIndexChatMessage.Content>
|
||||
)}
|
||||
</LlamaIndexChatMessage>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { LoaderIcon, TriangleAlert } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
|
||||
import { activeChathatUIAtom, activeChatIdAtom } from "@/atoms/chats/ui.atoms";
|
||||
import { generatePodcastMutationAtom } from "@/atoms/podcasts/podcast-mutation.atoms";
|
||||
import type { GeneratePodcastRequest } from "@/contracts/types/podcast.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatPanelView } from "./ChatPanelView";
|
||||
|
||||
export function ChatPanelContainer() {
|
||||
const {
|
||||
data: activeChatState,
|
||||
isLoading: isChatLoading,
|
||||
error: chatError,
|
||||
} = useAtomValue(activeChatAtom);
|
||||
const activeChatIdState = useAtomValue(activeChatIdAtom);
|
||||
const { isChatPannelOpen } = useAtomValue(activeChathatUIAtom);
|
||||
const { mutateAsync: generatePodcast, error: generatePodcastError } = useAtomValue(
|
||||
generatePodcastMutationAtom
|
||||
);
|
||||
|
||||
const handleGeneratePodcast = async (request: GeneratePodcastRequest) => {
|
||||
try {
|
||||
generatePodcast(request);
|
||||
toast.success(`Podcast generation started!`);
|
||||
} catch (error) {
|
||||
toast.error("Error generating podcast. Please try again later.");
|
||||
console.error("Error generating podcast:", JSON.stringify(generatePodcastError));
|
||||
}
|
||||
};
|
||||
|
||||
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-96" : "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;
|
||||
}
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback } from "react";
|
||||
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
|
||||
import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms";
|
||||
import { cn } from "@/lib/utils";
|
||||
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(activeChathatUIAtom);
|
||||
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]);
|
||||
|
||||
// biome-ignore-start lint/a11y/useSemanticElements: using div for custom layout — will convert later
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className={cn("w-full p-4", !isChatPannelOpen && "flex items-center justify-center")}>
|
||||
{isChatPannelOpen ? (
|
||||
<div className="space-y-3">
|
||||
{/* Show stale podcast warning if applicable */}
|
||||
{podcastIsStale && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="rounded-xl p-3 bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/20 border border-amber-200/50 dark:border-amber-800/50 shadow-sm"
|
||||
>
|
||||
<div className="flex gap-2 items-start">
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 10, -10, 0] }}
|
||||
transition={{ duration: 0.5, repeat: Infinity, repeatDelay: 3 }}
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
</motion.div>
|
||||
<div className="text-sm text-amber-900 dark:text-amber-100">
|
||||
<p className="font-semibold">Podcast Outdated</p>
|
||||
<p className="text-xs mt-1 opacity-80">
|
||||
{getPodcastStalenessMessage(
|
||||
chatDetails?.state_version || 0,
|
||||
podcast?.chat_state_version
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="relative"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGeneratePost}
|
||||
className={cn(
|
||||
"relative w-full rounded-2xl p-4 transition-all duration-300 cursor-pointer group overflow-hidden",
|
||||
"border-2",
|
||||
podcastIsStale
|
||||
? "bg-gradient-to-br from-amber-500/10 via-orange-500/10 to-amber-500/10 dark:from-amber-500/20 dark:via-orange-500/20 dark:to-amber-500/20 border-amber-400/50 hover:border-amber-400 hover:shadow-lg hover:shadow-amber-500/20"
|
||||
: "bg-gradient-to-br from-primary/10 via-primary/5 to-primary/10 border-primary/30 hover:border-primary/60 hover:shadow-lg hover:shadow-primary/20"
|
||||
)}
|
||||
>
|
||||
{/* Background gradient animation */}
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500",
|
||||
podcastIsStale
|
||||
? "bg-gradient-to-r from-transparent via-amber-400/10 to-transparent"
|
||||
: "bg-gradient-to-r from-transparent via-primary/10 to-transparent"
|
||||
)}
|
||||
animate={{
|
||||
x: ["-100%", "100%"],
|
||||
}}
|
||||
transition={{
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.div
|
||||
className={cn(
|
||||
"p-2.5 rounded-xl",
|
||||
podcastIsStale
|
||||
? "bg-amber-500/20 dark:bg-amber-500/30"
|
||||
: "bg-primary/20 dark:bg-primary/30"
|
||||
)}
|
||||
animate={{
|
||||
rotate: podcastIsStale ? [0, 360] : 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: podcastIsStale ? Infinity : 0,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{podcastIsStale ? (
|
||||
<RefreshCw className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</motion.div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">
|
||||
{podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{podcastIsStale
|
||||
? "Update with latest changes"
|
||||
: "Create podcasts of your chat"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/* ConfigModal positioned absolutely to avoid nesting buttons */}
|
||||
<div className="absolute top-4 right-4 z-20">
|
||||
<ConfigModal generatePodcast={generatePodcast} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : (
|
||||
<motion.button
|
||||
title={podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setChatUIState((prev) => ({
|
||||
...prev,
|
||||
isChatPannelOpen: !isChatPannelOpen,
|
||||
}))
|
||||
}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={cn(
|
||||
"p-2.5 rounded-full transition-colors shadow-sm",
|
||||
podcastIsStale
|
||||
? "bg-amber-500/20 hover:bg-amber-500/30 text-amber-600 dark:text-amber-400"
|
||||
: "bg-primary/20 hover:bg-primary/30 text-primary"
|
||||
)}
|
||||
>
|
||||
{podcastIsStale ? <RefreshCw className="h-5 w-5" /> : <Sparkles className="h-5 w-5" />}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
{podcast ? (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full border-t",
|
||||
!isChatPannelOpen && "flex items-center justify-center p-4"
|
||||
)}
|
||||
>
|
||||
{isChatPannelOpen ? (
|
||||
<PodcastPlayer compact podcast={podcast} />
|
||||
) : podcast ? (
|
||||
<motion.button
|
||||
title="Play Podcast"
|
||||
type="button"
|
||||
onClick={() => setChatUIState((prev) => ({ ...prev, isChatPannelOpen: true }))}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className="p-2.5 rounded-full bg-green-500/20 hover:bg-green-500/30 text-green-600 dark:text-green-400 transition-colors shadow-sm"
|
||||
>
|
||||
<Play className="h-5 w-5" />
|
||||
</motion.button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
// biome-ignore-end lint/a11y/useSemanticElements : using div for custom layout — will convert later
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useCallback, useContext, useState } from "react";
|
||||
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
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">
|
||||
Special user instructions
|
||||
</label>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Leave empty to use the default prompt
|
||||
</p>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 space-y-1">
|
||||
<p>Examples:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
<li>Make hosts speak in London street language</li>
|
||||
<li>Use real-world analogies and metaphors</li>
|
||||
<li>Add dramatic pauses like a late-night radio show</li>
|
||||
<li>Include 90s pop culture references</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,329 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Pause, Play, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import type { Podcast } from "@/contracts/types/podcast.types";
|
||||
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
|
||||
import { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton";
|
||||
|
||||
interface PodcastPlayerProps {
|
||||
podcast: Podcast | 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 {
|
||||
// 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 podcastsApiService.loadPodcast({
|
||||
request: { id: podcast.id },
|
||||
controller,
|
||||
});
|
||||
|
||||
const objectUrl = URL.createObjectURL(response);
|
||||
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-4 p-4">
|
||||
{/* Audio Visualizer */}
|
||||
<motion.div
|
||||
className="relative h-1 bg-gradient-to-r from-primary/20 via-primary/40 to-primary/20 rounded-full overflow-hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{isPlaying && (
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-primary to-transparent"
|
||||
animate={{
|
||||
x: ["-100%", "100%"],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Progress Bar with Time */}
|
||||
<div className="space-y-2">
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
min={0}
|
||||
max={duration || 100}
|
||||
step={0.1}
|
||||
onValueChange={handleSeek}
|
||||
className="w-full cursor-pointer"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||
<span className="font-mono">{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left: Volume */}
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button variant="ghost" size="icon" onClick={toggleMute} className="h-8 w-8">
|
||||
{isMuted ? (
|
||||
<VolumeX className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Center: Playback Controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={skipBackward}
|
||||
className="h-9 w-9"
|
||||
disabled={!duration}
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
animate={
|
||||
isPlaying
|
||||
? {
|
||||
boxShadow: [
|
||||
"0 0 0 0 rgba(var(--primary), 0)",
|
||||
"0 0 0 8px rgba(var(--primary), 0.1)",
|
||||
"0 0 0 0 rgba(var(--primary), 0)",
|
||||
],
|
||||
}
|
||||
: {}
|
||||
}
|
||||
transition={{ duration: 1.5, repeat: isPlaying ? Infinity : 0 }}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={togglePlayPause}
|
||||
className="h-10 w-10 rounded-full"
|
||||
disabled={!duration}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 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-9 w-9"
|
||||
disabled={!duration}
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Right: Placeholder for symmetry */}
|
||||
<div className="flex-1" />
|
||||
</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;
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { PodcastPlayer } from "./PodcastPlayer";
|
||||
export { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton";
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { getAnnotationData, type Message } from "@llamaindex/chat-ui";
|
||||
import { ExternalLink, FileText } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { SourceDetailSheet } from "./SourceDetailSheet";
|
||||
|
||||
interface Source {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
sourceType: string;
|
||||
}
|
||||
|
||||
interface SourceGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
sources: Source[];
|
||||
}
|
||||
|
||||
// New interfaces for the updated data format
|
||||
interface NodeMetadata {
|
||||
title: string;
|
||||
source_type: string;
|
||||
group_name: string;
|
||||
}
|
||||
|
||||
interface SourceNode {
|
||||
id: string;
|
||||
text: string;
|
||||
url: string;
|
||||
metadata: NodeMetadata;
|
||||
}
|
||||
|
||||
function getSourceIcon(type: string) {
|
||||
// Handle USER_SELECTED_ prefix
|
||||
const normalizedType = type.startsWith("USER_SELECTED_")
|
||||
? type.replace("USER_SELECTED_", "")
|
||||
: type;
|
||||
return getConnectorIcon(normalizedType, "h-4 w-4");
|
||||
}
|
||||
|
||||
function SourceCard({ source }: { source: Source }) {
|
||||
const hasUrl = source.url && source.url.trim() !== "";
|
||||
const chunkId = Number(source.id);
|
||||
const sourceType = source.sourceType;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Clean up the description for better display
|
||||
const cleanDescription = source.description
|
||||
.replace(/## Metadata\n\n/g, "")
|
||||
.replace(/\n+/g, " ")
|
||||
.trim();
|
||||
|
||||
const handleUrlClick = (e: React.MouseEvent, url: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
return (
|
||||
<SourceDetailSheet
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
chunkId={chunkId}
|
||||
sourceType={sourceType}
|
||||
title={source.title}
|
||||
description={source.description}
|
||||
url={source.url}
|
||||
>
|
||||
<SheetTrigger asChild>
|
||||
<Card className="border-muted hover:border-muted-foreground/20 transition-colors cursor-pointer">
|
||||
<CardHeader className="pb-3 pt-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-sm font-medium leading-tight line-clamp-2 flex-1">
|
||||
{source.title}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Badge variant="secondary" className="text-[10px] h-5 px-2 font-mono">
|
||||
#{chunkId}
|
||||
</Badge>
|
||||
{hasUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 flex-shrink-0 hover:bg-muted"
|
||||
onClick={(e) => handleUrlClick(e, source.url)}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 pb-3">
|
||||
<CardDescription className="text-xs line-clamp-3 leading-relaxed text-muted-foreground">
|
||||
{cleanDescription}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SheetTrigger>
|
||||
</SourceDetailSheet>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const annotations = getAnnotationData(message, "sources");
|
||||
|
||||
// Transform the new data format to the expected SourceGroup format
|
||||
const sourceGroups: SourceGroup[] = [];
|
||||
|
||||
if (Array.isArray(annotations) && annotations.length > 0) {
|
||||
// Extract all nodes from the response
|
||||
const allNodes: SourceNode[] = [];
|
||||
|
||||
annotations.forEach((item) => {
|
||||
if (item && typeof item === "object" && "nodes" in item && Array.isArray(item.nodes)) {
|
||||
allNodes.push(...item.nodes);
|
||||
}
|
||||
});
|
||||
|
||||
// Group nodes by source_type
|
||||
const groupedByType = allNodes.reduce(
|
||||
(acc, node) => {
|
||||
const sourceType = node.metadata.source_type;
|
||||
if (!acc[sourceType]) {
|
||||
acc[sourceType] = [];
|
||||
}
|
||||
acc[sourceType].push(node);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, SourceNode[]>
|
||||
);
|
||||
|
||||
// Convert grouped nodes to SourceGroup format
|
||||
Object.entries(groupedByType).forEach(([sourceType, nodes], index) => {
|
||||
if (nodes.length > 0) {
|
||||
const firstNode = nodes[0];
|
||||
sourceGroups.push({
|
||||
id: index + 100, // Generate unique ID
|
||||
name: firstNode.metadata.group_name,
|
||||
type: sourceType,
|
||||
sources: nodes.map((node) => ({
|
||||
id: node.id,
|
||||
title: node.metadata.title,
|
||||
description: node.text,
|
||||
url: node.url || "",
|
||||
sourceType: sourceType,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (sourceGroups.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalSources = sourceGroups.reduce((acc, group) => acc + group.sources.length, 0);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-fit">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
View Sources ({totalSources})
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[400px] sm:w-[540px] md:w-[640px] lg:w-[720px] xl:w-[800px] sm:max-w-[540px] md:max-w-[640px] lg:max-w-[720px] xl:max-w-[800px] flex flex-col p-0 overflow-hidden">
|
||||
<SheetHeader className="px-6 py-4 border-b flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<SheetTitle className="text-lg font-semibold">Sources</SheetTitle>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{totalSources} {totalSources === 1 ? "source" : "sources"}
|
||||
</Badge>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<Tabs defaultValue={sourceGroups[0]?.type} className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-shrink-0 w-full overflow-x-auto px-6 pt-4 scrollbar-none">
|
||||
<TabsList className="flex w-max min-w-full bg-muted/50">
|
||||
{sourceGroups.map((group) => (
|
||||
<TabsTrigger
|
||||
key={group.type}
|
||||
value={group.type}
|
||||
className="flex items-center gap-2 whitespace-nowrap px-4 data-[state=active]:bg-background data-[state=active]:shadow-sm"
|
||||
>
|
||||
{getSourceIcon(group.type)}
|
||||
<span className="truncate max-w-[120px] md:max-w-[180px] lg:max-w-none">
|
||||
{group.name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 text-xs flex-shrink-0">
|
||||
{group.sources.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
{sourceGroups.map((group) => (
|
||||
<TabsContent
|
||||
key={group.type}
|
||||
value={group.type}
|
||||
className="flex-1 min-h-0 mt-0 px-6 pb-6 data-[state=active]:flex data-[state=active]:flex-col"
|
||||
>
|
||||
<div className="h-full overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
|
||||
<div className="grid gap-3 pt-4 grid-cols-1 lg:grid-cols-2">
|
||||
{group.sources.map((source) => (
|
||||
<SourceCard key={source.id} source={source} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { getAnnotationData, type Message } from "@llamaindex/chat-ui";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function TerminalDisplay({ message, open }: { message: Message; open: boolean }) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(!open);
|
||||
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (bottomRef.current) {
|
||||
bottomRef.current.scrollTo({
|
||||
top: bottomRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get the last assistant message that's not being typed
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
interface TerminalInfo {
|
||||
id: number;
|
||||
text: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const events = getAnnotationData(message, "TERMINAL_INFO") as TerminalInfo[];
|
||||
|
||||
if (events.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden font-mono text-sm shadow-lg">
|
||||
{/* Terminal Header */}
|
||||
<Button
|
||||
className="w-full bg-gray-800 px-4 py-2 flex items-center gap-2 border-b border-gray-700 cursor-pointer hover:bg-gray-750 transition-colors"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
variant="ghost"
|
||||
type="button"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<div className="text-gray-400 text-xs ml-2 flex-1">
|
||||
Agent Process Terminal ({events.length} events)
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
{isCollapsed ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<title>Collapse</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<title>Expand</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 15l7-7 7 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Terminal Content (animated expand/collapse) */}
|
||||
<div
|
||||
className={`overflow-hidden bg-gray-900 transition-[max-height,opacity] duration-300 ease-in-out ${
|
||||
isCollapsed ? "max-h-0 opacity-0" : "max-h-64 opacity-100"
|
||||
}`}
|
||||
style={{ maxHeight: isCollapsed ? "0px" : "16rem" }}
|
||||
aria-hidden={isCollapsed}
|
||||
>
|
||||
<div ref={bottomRef} className="h-64 overflow-y-auto p-4 space-y-1">
|
||||
{events.map((event, index) => (
|
||||
<div key={`${event.id}-${index}`} className="text-green-400">
|
||||
<span className="text-blue-400">$</span>
|
||||
<span className="text-yellow-400 ml-2">[{event.type || ""}]</span>
|
||||
<span className="text-gray-300 ml-4 mt-1 pl-2 border-l-2 border-gray-600">
|
||||
{event.text || ""}...
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<div className="text-gray-500 italic">No agent events to display...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import { ExternalLink } from "lucide-react";
|
||||
import { memo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getConnectorIcon } from "./ConnectorComponents";
|
||||
import type { Source } from "./types";
|
||||
|
||||
type CitationProps = {
|
||||
citationId: number;
|
||||
citationText: string;
|
||||
position: number;
|
||||
source: Source | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Citation component to handle individual citations
|
||||
*/
|
||||
export const Citation = memo(({ citationId, citationText, position, source }: CitationProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const citationKey = `citation-${citationId}-${position}`;
|
||||
|
||||
if (!source) return <>{citationText}</>;
|
||||
|
||||
return (
|
||||
<span key={citationKey} className="relative inline-flex items-center">
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<sup>
|
||||
<span className="inline-flex items-center justify-center text-primary cursor-pointer bg-primary/10 hover:bg-primary/15 w-4 h-4 rounded-full text-[10px] font-medium ml-0.5 transition-colors border border-primary/20 shadow-sm">
|
||||
{citationId}
|
||||
</span>
|
||||
</sup>
|
||||
</DropdownMenuTrigger>
|
||||
{open && (
|
||||
<DropdownMenuContent align="start" className="w-80 p-0" forceMount>
|
||||
<Card className="border-0 shadow-none">
|
||||
<div className="p-3 flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-7 h-7 flex items-center justify-center bg-muted rounded-full">
|
||||
{getConnectorIcon(source.connectorType || "")}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-sm text-card-foreground">{source.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{source.description}</p>
|
||||
<div className="mt-2 flex items-center text-xs text-muted-foreground">
|
||||
<span className="truncate max-w-[200px]">{source.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full"
|
||||
onClick={() => window.open(source.url, "_blank", "noopener,noreferrer")}
|
||||
title="Open in new tab"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</DropdownMenuContent>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
Citation.displayName = "Citation";
|
||||
|
||||
/**
|
||||
* Function to render text with citations
|
||||
*/
|
||||
export const renderTextWithCitations = (
|
||||
text: string,
|
||||
getCitationSource: (id: number) => Source | null
|
||||
) => {
|
||||
// Regular expression to find citation patterns like [1], [2], etc.
|
||||
const citationRegex = /\[(\d+)\]/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null = citationRegex.exec(text);
|
||||
let position = 0;
|
||||
|
||||
while (match !== null) {
|
||||
// Add text before the citation
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.substring(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// Add the citation component
|
||||
const citationId = parseInt(match[1], 10);
|
||||
parts.push(
|
||||
<Citation
|
||||
key={`citation-${citationId}-${position}`}
|
||||
citationId={citationId}
|
||||
citationText={match[0]}
|
||||
position={position}
|
||||
source={getCitationSource(citationId)}
|
||||
/>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
position++;
|
||||
match = citationRegex.exec(text);
|
||||
}
|
||||
|
||||
// Add any remaining text after the last citation
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.substring(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark, oneLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||
|
||||
// Constants for styling and configuration
|
||||
const COPY_TIMEOUT = 2000;
|
||||
|
||||
const BASE_CUSTOM_STYLE = {
|
||||
margin: 0,
|
||||
borderRadius: "0.375rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1.5rem",
|
||||
border: "none",
|
||||
} as const;
|
||||
|
||||
const LINE_PROPS_STYLE = {
|
||||
wordBreak: "break-all" as const,
|
||||
whiteSpace: "pre-wrap" as const,
|
||||
border: "none",
|
||||
borderBottom: "none",
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
margin: "0.25rem 0",
|
||||
} as const;
|
||||
|
||||
const CODE_TAG_PROPS = {
|
||||
className: "font-mono",
|
||||
style: {
|
||||
border: "none",
|
||||
background: "var(--syntax-bg)",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// TypeScript interfaces
|
||||
interface CodeBlockProps {
|
||||
children: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
type LanguageRenderer = (props: { code: string }) => React.JSX.Element;
|
||||
|
||||
interface SyntaxStyle {
|
||||
[key: string]: React.CSSProperties;
|
||||
}
|
||||
|
||||
// Memoized fallback component for SSR/hydration
|
||||
const FallbackCodeBlock = memo(({ children }: { children: string }) => (
|
||||
<div className="bg-muted p-4 rounded-md">
|
||||
<pre className="m-0 p-0 border-0">
|
||||
<code className="text-xs font-mono border-0 leading-6">{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
));
|
||||
|
||||
FallbackCodeBlock.displayName = "FallbackCodeBlock";
|
||||
|
||||
// Code block component with syntax highlighting and copy functionality
|
||||
export const CodeBlock = memo<CodeBlockProps>(({ children, language }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { resolvedTheme, theme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Prevent hydration issues
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Memoize theme detection
|
||||
const isDarkTheme = useMemo(
|
||||
() => mounted && (resolvedTheme === "dark" || theme === "dark"),
|
||||
[mounted, resolvedTheme, theme]
|
||||
);
|
||||
|
||||
// Memoize syntax theme selection
|
||||
const syntaxTheme = useMemo(() => (isDarkTheme ? oneDark : oneLight), [isDarkTheme]);
|
||||
|
||||
// Memoize enhanced style with theme-specific modifications
|
||||
const enhancedStyle = useMemo<SyntaxStyle>(
|
||||
() => ({
|
||||
...syntaxTheme,
|
||||
'pre[class*="language-"]': {
|
||||
...syntaxTheme['pre[class*="language-"]'],
|
||||
margin: 0,
|
||||
border: "none",
|
||||
borderRadius: "0.375rem",
|
||||
background: "var(--syntax-bg)",
|
||||
},
|
||||
'code[class*="language-"]': {
|
||||
...syntaxTheme['code[class*="language-"]'],
|
||||
border: "none",
|
||||
background: "var(--syntax-bg)",
|
||||
},
|
||||
}),
|
||||
[syntaxTheme]
|
||||
);
|
||||
|
||||
// Memoize custom style with background
|
||||
const customStyle = useMemo(
|
||||
() => ({
|
||||
...BASE_CUSTOM_STYLE,
|
||||
backgroundColor: "var(--syntax-bg)",
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Memoized copy handler
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(children);
|
||||
setCopied(true);
|
||||
const timeoutId = setTimeout(() => setCopied(false), COPY_TIMEOUT);
|
||||
return () => clearTimeout(timeoutId);
|
||||
} catch (error) {
|
||||
console.warn("Failed to copy code to clipboard:", error);
|
||||
}
|
||||
}, [children]);
|
||||
|
||||
// Memoized line props with style
|
||||
const lineProps = useMemo(
|
||||
() => ({
|
||||
style: LINE_PROPS_STYLE,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Early return for non-mounted state
|
||||
if (!mounted) {
|
||||
return <FallbackCodeBlock>{children}</FallbackCodeBlock>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative my-4 group">
|
||||
<div className="absolute right-2 top-2 z-10">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 rounded-md bg-background/80 hover:bg-background border border-border flex items-center justify-center transition-colors"
|
||||
aria-label="Copy code"
|
||||
type="button"
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={14} className="text-green-500" />
|
||||
) : (
|
||||
<Copy size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={language || "text"}
|
||||
style={enhancedStyle}
|
||||
customStyle={customStyle}
|
||||
codeTagProps={CODE_TAG_PROPS}
|
||||
showLineNumbers={false}
|
||||
wrapLines={false}
|
||||
lineProps={lineProps}
|
||||
PreTag="div"
|
||||
>
|
||||
{children}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CodeBlock.displayName = "CodeBlock";
|
||||
|
||||
// Optimized language renderer factory with memoization
|
||||
const createLanguageRenderer = (lang: string): LanguageRenderer => {
|
||||
const renderer = ({ code }: { code: string }) => <CodeBlock language={lang}>{code}</CodeBlock>;
|
||||
renderer.displayName = `LanguageRenderer(${lang})`;
|
||||
return renderer;
|
||||
};
|
||||
|
||||
// Pre-defined supported languages for better maintainability
|
||||
const SUPPORTED_LANGUAGES = [
|
||||
"javascript",
|
||||
"typescript",
|
||||
"python",
|
||||
"java",
|
||||
"csharp",
|
||||
"cpp",
|
||||
"c",
|
||||
"php",
|
||||
"ruby",
|
||||
"go",
|
||||
"rust",
|
||||
"swift",
|
||||
"kotlin",
|
||||
"scala",
|
||||
"sql",
|
||||
"json",
|
||||
"xml",
|
||||
"yaml",
|
||||
"bash",
|
||||
"shell",
|
||||
"powershell",
|
||||
"dockerfile",
|
||||
"html",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"markdown",
|
||||
"text",
|
||||
] as const;
|
||||
|
||||
// Generate language renderers efficiently
|
||||
export const languageRenderers: Record<string, LanguageRenderer> = Object.fromEntries(
|
||||
SUPPORTED_LANGUAGES.map((lang) => [lang, createLanguageRenderer(lang)])
|
||||
);
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { Connector } from "./types";
|
||||
|
||||
/**
|
||||
* Displays a small icon for a connector type
|
||||
*/
|
||||
export const ConnectorIcon = ({ type, index = 0 }: { type: string; index?: number }) => (
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex items-center justify-center bg-muted border border-background"
|
||||
style={{ zIndex: 10 - index }}
|
||||
>
|
||||
{getConnectorIcon(type)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Displays a count indicator for additional connectors
|
||||
*/
|
||||
export const ConnectorCountBadge = ({ count }: { count: number }) => (
|
||||
<div className="w-4 h-4 rounded-full flex items-center justify-center bg-primary text-primary-foreground text-[8px] font-medium border border-background z-0">
|
||||
+{count}
|
||||
</div>
|
||||
);
|
||||
|
||||
type ConnectorButtonProps = {
|
||||
selectedConnectors: string[];
|
||||
onClick: () => void;
|
||||
connectorSources: Connector[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Button that displays selected connectors and opens connector selection dialog
|
||||
*/
|
||||
export const ConnectorButton = ({
|
||||
selectedConnectors,
|
||||
onClick,
|
||||
connectorSources,
|
||||
}: ConnectorButtonProps) => {
|
||||
const totalConnectors = connectorSources.length;
|
||||
const selectedCount = selectedConnectors.length;
|
||||
const progressPercentage = (selectedCount / totalConnectors) * 100;
|
||||
|
||||
// Get the name of a single selected connector
|
||||
const getSingleConnectorName = () => {
|
||||
const connector = connectorSources.find((c) => c.type === selectedConnectors[0]);
|
||||
return connector?.name || "";
|
||||
};
|
||||
|
||||
// Get display text based on selection count
|
||||
const getDisplayText = () => {
|
||||
if (selectedCount === totalConnectors) return "All Connectors";
|
||||
if (selectedCount === 1) return getSingleConnectorName();
|
||||
return `${selectedCount} Connectors`;
|
||||
};
|
||||
|
||||
// Render the empty state (no connectors selected)
|
||||
const renderEmptyState = () => (
|
||||
<>
|
||||
<Plus className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Select Connectors</span>
|
||||
</>
|
||||
);
|
||||
|
||||
// Render the selected connectors preview
|
||||
const renderSelectedConnectors = () => (
|
||||
<>
|
||||
<div className="flex -space-x-1.5 mr-1">
|
||||
{/* Show up to 3 connector icons */}
|
||||
{selectedConnectors.slice(0, 3).map((type, index) => (
|
||||
<ConnectorIcon key={type} type={type} index={index} />
|
||||
))}
|
||||
|
||||
{/* Show count indicator if more than 3 connectors are selected */}
|
||||
{selectedCount > 3 && <ConnectorCountBadge count={selectedCount - 3} />}
|
||||
</div>
|
||||
|
||||
{/* Display text */}
|
||||
<span className="font-medium">{getDisplayText()}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-2 text-xs font-medium rounded-md border-border relative overflow-hidden group"
|
||||
onClick={onClick}
|
||||
aria-label={
|
||||
selectedCount === 0 ? "Select Connectors" : `${selectedCount} connectors selected`
|
||||
}
|
||||
>
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-1 bg-primary"
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5 z-10 relative">
|
||||
{selectedCount === 0 ? renderEmptyState() : renderSelectedConnectors()}
|
||||
<ChevronDown className="h-3 w-3 ml-0.5 text-muted-foreground opacity-70" />
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,604 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowUpDown, Calendar, FileText, Filter, Plus, Search } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { Document, DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
interface DocumentsDataTableProps {
|
||||
searchSpaceId: number;
|
||||
onSelectionChange: (documents: Document[]) => void;
|
||||
onDone: () => void;
|
||||
initialSelectedDocuments?: Document[];
|
||||
}
|
||||
|
||||
function useDebounced<T>(value: T, delay = 300) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
const columns: ColumnDef<Document>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
className="h-8 px-1 sm:px-2 font-medium text-left justify-start"
|
||||
>
|
||||
<FileText className="mr-1 sm:mr-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">Title</span>
|
||||
<span className="sm:hidden">Doc</span>
|
||||
<ArrowUpDown className="ml-1 sm:ml-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const title = row.getValue("title") as string;
|
||||
return (
|
||||
<div
|
||||
className="font-medium max-w-[120px] sm:max-w-[250px] truncate text-xs sm:text-sm"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "document_type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue("document_type") as DocumentType;
|
||||
return (
|
||||
<div className="flex items-center gap-2" title={String(type)}>
|
||||
<span className="text-primary">{getConnectorIcon(String(type))}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 80,
|
||||
meta: {
|
||||
className: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "content",
|
||||
header: "Preview",
|
||||
cell: ({ row }) => {
|
||||
const content = row.getValue("content") as string;
|
||||
return (
|
||||
<div
|
||||
className="text-muted-foreground max-w-[150px] sm:max-w-[350px] truncate text-[10px] sm:text-sm"
|
||||
title={content}
|
||||
>
|
||||
<span className="sm:hidden">{content.substring(0, 30)}...</span>
|
||||
<span className="hidden sm:inline">{content.substring(0, 100)}...</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
className="h-8 px-1 sm:px-2 font-medium"
|
||||
>
|
||||
<Calendar className="mr-1 sm:mr-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">Created</span>
|
||||
<span className="sm:hidden">Date</span>
|
||||
<ArrowUpDown className="ml-1 sm:ml-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.getValue("created_at"));
|
||||
return (
|
||||
<div className="text-xs sm:text-sm whitespace-nowrap">
|
||||
<span className="hidden sm:inline">
|
||||
{date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<span className="sm:hidden">
|
||||
{date.toLocaleDateString("en-US", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 80,
|
||||
},
|
||||
];
|
||||
|
||||
export function DocumentsDataTable({
|
||||
searchSpaceId,
|
||||
onSelectionChange,
|
||||
onDone,
|
||||
initialSelectedDocuments = [],
|
||||
}: DocumentsDataTableProps) {
|
||||
const router = useRouter();
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounced(search, 300);
|
||||
const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentTypeEnum[]>([]);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const { data: typeCounts } = useAtomValue(documentTypeCountsAtom);
|
||||
|
||||
const fetchQueryParams = useMemo(
|
||||
() => ({
|
||||
search_space_id: searchSpaceId,
|
||||
page: pageIndex,
|
||||
page_size: pageSize,
|
||||
...(documentTypeFilter.length > 0 && { document_types: documentTypeFilter }),
|
||||
}),
|
||||
[searchSpaceId, pageIndex, pageSize, documentTypeFilter, debouncedSearch]
|
||||
);
|
||||
|
||||
const searchQueryParams = useMemo(() => {
|
||||
return {
|
||||
search_space_id: searchSpaceId,
|
||||
page: pageIndex,
|
||||
page_size: pageSize,
|
||||
...(documentTypeFilter.length > 0 && { document_types: documentTypeFilter }),
|
||||
title: debouncedSearch,
|
||||
};
|
||||
}, [debouncedSearch, searchSpaceId, pageIndex, pageSize, documentTypeFilter, debouncedSearch]);
|
||||
|
||||
// Use query for fetching documents
|
||||
const { data: documents, isLoading: isDocumentsLoading } = useQuery({
|
||||
queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams),
|
||||
queryFn: () => documentsApiService.getDocuments({ queryParams: fetchQueryParams }),
|
||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
||||
enabled: !!searchSpaceId && !debouncedSearch.trim(),
|
||||
});
|
||||
|
||||
// Seaching
|
||||
const { data: searchedDocuments, isLoading: isSearchedDocumentsLoading } = useQuery({
|
||||
queryKey: cacheKeys.documents.withQueryParams(searchQueryParams),
|
||||
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
|
||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
||||
enabled: !!searchSpaceId && !!debouncedSearch.trim(),
|
||||
});
|
||||
|
||||
// Use query data when not searching, otherwise use hook data
|
||||
const actualDocuments = debouncedSearch.trim()
|
||||
? searchedDocuments?.items || []
|
||||
: documents?.items || [];
|
||||
const actualTotal = debouncedSearch.trim()
|
||||
? searchedDocuments?.total || 0
|
||||
: documents?.total || 0;
|
||||
const actualLoading = debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading;
|
||||
|
||||
// Memoize initial row selection to prevent infinite loops
|
||||
const initialRowSelection = useMemo(() => {
|
||||
if (!initialSelectedDocuments.length) return {};
|
||||
|
||||
const selection: Record<string, boolean> = {};
|
||||
initialSelectedDocuments.forEach((selectedDoc) => {
|
||||
selection[selectedDoc.id] = true;
|
||||
});
|
||||
return selection;
|
||||
}, [initialSelectedDocuments]);
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>(
|
||||
() => initialRowSelection
|
||||
);
|
||||
|
||||
// Maintain a separate state for actually selected documents (across all pages)
|
||||
const [selectedDocumentsMap, setSelectedDocumentsMap] = useState<Map<number, Document>>(() => {
|
||||
const map = new Map<number, Document>();
|
||||
initialSelectedDocuments.forEach((doc) => map.set(doc.id, doc));
|
||||
return map;
|
||||
});
|
||||
|
||||
// Track the last notified selection to avoid redundant parent calls
|
||||
const lastNotifiedSelection = useRef<string>("");
|
||||
|
||||
// Update row selection only when initialSelectedDocuments changes (not rowSelection itself)
|
||||
useEffect(() => {
|
||||
const initialKeys = Object.keys(initialRowSelection);
|
||||
if (initialKeys.length === 0) return;
|
||||
|
||||
const currentKeys = Object.keys(rowSelection);
|
||||
// Quick length check before expensive comparison
|
||||
if (currentKeys.length === initialKeys.length) {
|
||||
// Check if all keys match (order doesn't matter for Sets)
|
||||
const hasAllKeys = initialKeys.every((key) => rowSelection[key]);
|
||||
if (hasAllKeys) return;
|
||||
}
|
||||
|
||||
setRowSelection(initialRowSelection);
|
||||
}, [initialRowSelection]); // Remove rowSelection from dependencies to prevent loop
|
||||
|
||||
// Update the selected documents map when row selection changes
|
||||
useEffect(() => {
|
||||
if (!actualDocuments || actualDocuments.length === 0) return;
|
||||
|
||||
setSelectedDocumentsMap((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
let hasChanges = false;
|
||||
|
||||
// Process only current page documents
|
||||
for (const doc of actualDocuments) {
|
||||
const docId = doc.id;
|
||||
const isSelected = rowSelection[docId.toString()];
|
||||
const wasInMap = newMap.has(docId);
|
||||
|
||||
if (isSelected && !wasInMap) {
|
||||
newMap.set(docId, doc);
|
||||
hasChanges = true;
|
||||
} else if (!isSelected && wasInMap) {
|
||||
newMap.delete(docId);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Return same reference if no changes to avoid unnecessary re-renders
|
||||
return hasChanges ? newMap : prev;
|
||||
});
|
||||
}, [rowSelection, documents]);
|
||||
|
||||
// Memoize selected documents array
|
||||
const selectedDocumentsArray = useMemo(() => {
|
||||
return Array.from(selectedDocumentsMap.values());
|
||||
}, [selectedDocumentsMap]);
|
||||
|
||||
// Notify parent of selection changes only when content actually changes
|
||||
useEffect(() => {
|
||||
// Create a stable string representation for comparison
|
||||
const selectionKey = selectedDocumentsArray
|
||||
.map((d) => d.id)
|
||||
.sort()
|
||||
.join(",");
|
||||
|
||||
// Skip if selection hasn't actually changed
|
||||
if (selectionKey === lastNotifiedSelection.current) return;
|
||||
|
||||
lastNotifiedSelection.current = selectionKey;
|
||||
onSelectionChange(selectedDocumentsArray);
|
||||
}, [selectedDocumentsArray, onSelectionChange]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: actualDocuments || [],
|
||||
columns,
|
||||
getRowId: (row) => row.id.toString(),
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
manualPagination: true,
|
||||
pageCount: Math.ceil(actualTotal / pageSize),
|
||||
state: { sorting, rowSelection, pagination: { pageIndex, pageSize } },
|
||||
});
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
setRowSelection({});
|
||||
setSelectedDocumentsMap(new Map());
|
||||
}, []);
|
||||
|
||||
const handleSelectPage = useCallback(() => {
|
||||
const currentPageRows = table.getRowModel().rows;
|
||||
const newSelection = { ...rowSelection };
|
||||
currentPageRows.forEach((row) => {
|
||||
newSelection[row.id] = true;
|
||||
});
|
||||
setRowSelection(newSelection);
|
||||
}, [table, rowSelection]);
|
||||
|
||||
const handleToggleType = useCallback((type: DocumentTypeEnum, checked: boolean) => {
|
||||
setDocumentTypeFilter((prev) => {
|
||||
if (checked) {
|
||||
return [...prev, type];
|
||||
}
|
||||
return prev.filter((t) => t !== type);
|
||||
});
|
||||
setPageIndex(0); // Reset to first page when filter changes
|
||||
}, []);
|
||||
|
||||
const selectedCount = selectedDocumentsMap.size;
|
||||
|
||||
// Get available document types from type counts (memoized)
|
||||
const availableTypes = useMemo(() => {
|
||||
const types = typeCounts ? (Object.keys(typeCounts) as DocumentTypeEnum[]) : [];
|
||||
return types.length > 0 ? types.sort() : [];
|
||||
}, [typeCounts]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-3 md:space-y-4">
|
||||
{/* Header Controls */}
|
||||
<div className="space-y-3 md:space-y-4 flex-shrink-0">
|
||||
{/* Search and Filter Row */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||
<div className="relative flex-1 max-w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search documents..."
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPageIndex(0); // Reset to first page on search
|
||||
}}
|
||||
className="pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full sm:w-auto">
|
||||
<Filter className="mr-2 h-4 w-4 opacity-60" />
|
||||
Type
|
||||
{documentTypeFilter.length > 0 && (
|
||||
<span className="ml-2 inline-flex h-5 items-center rounded border border-border bg-background px-1.5 text-[0.625rem] font-medium text-muted-foreground/70">
|
||||
{documentTypeFilter.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-3" align="start">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">Filter by Type</div>
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{availableTypes.map((type) => (
|
||||
<div key={type} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`type-${type}`}
|
||||
checked={documentTypeFilter.includes(type)}
|
||||
onCheckedChange={(checked) => handleToggleType(type, !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`type-${type}`}
|
||||
className="flex grow justify-between gap-2 font-normal text-sm cursor-pointer"
|
||||
>
|
||||
<span>{type.replace(/_/g, " ")}</span>
|
||||
<span className="text-xs text-muted-foreground">{typeCounts?.[type]}</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{documentTypeFilter.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => {
|
||||
setDocumentTypeFilter([]);
|
||||
setPageIndex(0);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Action Controls Row */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{selectedCount} selected {actualLoading && "· Loading..."}
|
||||
</span>
|
||||
<div className="hidden sm:block h-4 w-px bg-border mx-2" />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
disabled={selectedCount === 0}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSelectPage}
|
||||
className="text-xs sm:text-sm"
|
||||
disabled={actualLoading}
|
||||
>
|
||||
Select Page
|
||||
</Button>
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={(v) => {
|
||||
setPageSize(Number(v));
|
||||
setPageIndex(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[100px] h-8 text-xs">
|
||||
<SelectValue>{pageSize} per page</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[10, 25, 50, 100].map((size) => (
|
||||
<SelectItem key={size} value={size.toString()}>
|
||||
{size} per page
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onDone}
|
||||
disabled={selectedCount === 0}
|
||||
className="w-full sm:w-auto sm:min-w-[100px]"
|
||||
>
|
||||
Done ({selectedCount})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Container */}
|
||||
<div className="border rounded-lg flex-1 min-h-0 overflow-hidden bg-background">
|
||||
<div className="overflow-auto h-full">
|
||||
{actualLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">Loading documents...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="border-b">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} className="h-12 text-xs sm:text-sm">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="hover:bg-muted/30"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-3 text-xs sm:text-sm">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-64">
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<div className="rounded-full bg-muted p-3">
|
||||
<FileText className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-2 text-center max-w-sm">
|
||||
<h3 className="font-semibold">No documents found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get started by adding your first data source to build your knowledge
|
||||
base.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/sources/add`)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Sources
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Pagination */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs sm:text-sm text-muted-foreground border-t pt-3 md:pt-4 flex-shrink-0">
|
||||
<div className="text-center sm:text-left">
|
||||
Showing {pageIndex * pageSize + 1} to {Math.min((pageIndex + 1) * pageSize, actualTotal)}{" "}
|
||||
of {actualTotal} documents
|
||||
</div>
|
||||
<div className="flex items-center justify-center sm:justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
|
||||
disabled={pageIndex === 0 || actualLoading}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<div className="flex items-center space-x-1 text-xs sm:text-sm">
|
||||
<span>Page</span>
|
||||
<strong>{pageIndex + 1}</strong>
|
||||
<span>of</span>
|
||||
<strong>{Math.ceil(actualTotal / pageSize)}</strong>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex((p) => p + 1)}
|
||||
disabled={pageIndex >= Math.ceil(actualTotal / pageSize) - 1 || actualLoading}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
/**
|
||||
* 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.";
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import { type RefObject, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Function to scroll to the bottom of a container
|
||||
*/
|
||||
export const scrollToBottom = (ref: RefObject<HTMLDivElement>) => {
|
||||
ref.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to scroll to bottom when messages change
|
||||
*/
|
||||
export const useScrollToBottom = (ref: RefObject<HTMLDivElement>, dependencies: any[]) => {
|
||||
useEffect(() => {
|
||||
scrollToBottom(ref);
|
||||
}, dependencies);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to check scroll position and update indicators
|
||||
*/
|
||||
export const updateScrollIndicators = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
setCanScrollLeft: (value: boolean) => void,
|
||||
setCanScrollRight: (value: boolean) => void
|
||||
) => {
|
||||
if (tabsListRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = tabsListRef.current;
|
||||
setCanScrollLeft(scrollLeft > 0);
|
||||
setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 10); // 10px buffer
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to initialize scroll indicators and add resize listener
|
||||
*/
|
||||
export const useScrollIndicators = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
setCanScrollLeft: (value: boolean) => void,
|
||||
setCanScrollRight: (value: boolean) => void
|
||||
) => {
|
||||
const updateIndicators = () =>
|
||||
updateScrollIndicators(tabsListRef, setCanScrollLeft, setCanScrollRight);
|
||||
|
||||
useEffect(() => {
|
||||
updateIndicators();
|
||||
// Add resize listener to update indicators when window size changes
|
||||
window.addEventListener("resize", updateIndicators);
|
||||
return () => window.removeEventListener("resize", updateIndicators);
|
||||
}, [updateIndicators]);
|
||||
|
||||
return updateIndicators;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to scroll tabs list left
|
||||
*/
|
||||
export const scrollTabsLeft = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
updateIndicators: () => void
|
||||
) => {
|
||||
if (tabsListRef.current) {
|
||||
tabsListRef.current.scrollBy({ left: -200, behavior: "smooth" });
|
||||
// Update indicators after scrolling
|
||||
setTimeout(updateIndicators, 300);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to scroll tabs list right
|
||||
*/
|
||||
export const scrollTabsRight = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
updateIndicators: () => void
|
||||
) => {
|
||||
if (tabsListRef.current) {
|
||||
tabsListRef.current.scrollBy({ left: 200, behavior: "smooth" });
|
||||
// Update indicators after scrolling
|
||||
setTimeout(updateIndicators, 300);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import type React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type SegmentedControlProps<T extends string> = {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: Array<{
|
||||
value: T;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A segmented control component for selecting between different options
|
||||
*/
|
||||
function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div className="flex h-7 rounded-md border border-border overflow-hidden">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
className={`flex h-full items-center gap-1 px-2 text-xs transition-colors ${
|
||||
value === option.value ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||
}`}
|
||||
onClick={() => onChange(option.value)}
|
||||
aria-pressed={value === option.value}
|
||||
>
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SegmentedControl;
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, ChevronUp, ExternalLink, Loader2 } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SourceDetailSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
chunkId: number;
|
||||
sourceType: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const formatDocumentType = (type: string) => {
|
||||
return type
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
export function SourceDetailSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
chunkId,
|
||||
sourceType,
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
children,
|
||||
}: SourceDetailSheetProps) {
|
||||
const chunksContainerRef = useRef<HTMLDivElement>(null);
|
||||
const highlightedChunkRef = useRef<HTMLDivElement>(null);
|
||||
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||
|
||||
const {
|
||||
data: document,
|
||||
isLoading: isDocumentByChunkFetching,
|
||||
error: documentByChunkFetchingError,
|
||||
} = useQuery({
|
||||
queryKey: cacheKeys.documents.byChunk(chunkId.toString()),
|
||||
queryFn: () => documentsApiService.getDocumentByChunk({ chunk_id: chunkId }),
|
||||
enabled: !!chunkId && open,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// Check if this is a source type that should render directly from node
|
||||
const isDirectRenderSource =
|
||||
sourceType === "TAVILY_API" ||
|
||||
sourceType === "LINKUP_API" ||
|
||||
sourceType === "SEARXNG_API" ||
|
||||
sourceType === "BAIDU_SEARCH_API";
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to highlighted chunk when document loads
|
||||
if (document) {
|
||||
setTimeout(() => {
|
||||
highlightedChunkRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}, [document, open]);
|
||||
|
||||
const handleUrlClick = (e: React.MouseEvent, clickUrl: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(clickUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
{children}
|
||||
<SheetContent side="right" className="w-full sm:max-w-5xl lg:max-w-7xl">
|
||||
<SheetHeader className="px-6 py-4 border-b">
|
||||
<SheetTitle className="flex items-center gap-3 text-lg">
|
||||
{getConnectorIcon(sourceType)}
|
||||
{document?.title || title}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-base mt-2">
|
||||
{document
|
||||
? formatDocumentType(document.document_type)
|
||||
: sourceType && formatDocumentType(sourceType)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{!isDirectRenderSource && isDocumentByChunkFetching && (
|
||||
<div className="flex items-center justify-center h-64 px-6">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDirectRenderSource && documentByChunkFetchingError && (
|
||||
<div className="flex items-center justify-center h-64 px-6">
|
||||
<p className="text-sm text-destructive">
|
||||
{documentByChunkFetchingError.message || "Failed to load document"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Direct render for web search providers */}
|
||||
{isDirectRenderSource && (
|
||||
<ScrollArea className="h-[calc(100vh-10rem)]">
|
||||
<div className="px-6 py-4">
|
||||
{/* External Link */}
|
||||
{url && (
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={(e) => handleUrlClick(e, url)}
|
||||
className="w-full py-3"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open in Browser
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Information */}
|
||||
<div className="mb-8 p-6 bg-muted/50 rounded-lg border">
|
||||
<h3 className="text-base font-semibold mb-4">Source Information</h3>
|
||||
<div className="text-sm text-muted-foreground mb-3 font-medium">
|
||||
{title || "Untitled"}
|
||||
</div>
|
||||
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{description || "No content available"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* API-fetched document content */}
|
||||
{!isDirectRenderSource && document && (
|
||||
<ScrollArea className="h-[calc(100vh-10rem)]">
|
||||
<div className="px-6 py-4">
|
||||
{/* Document Metadata */}
|
||||
{document.document_metadata && Object.keys(document.document_metadata).length > 0 && (
|
||||
<div className="mb-8 p-6 bg-muted/50 rounded-lg border">
|
||||
<h3 className="text-base font-semibold mb-4">Document Information</h3>
|
||||
<dl className="grid grid-cols-1 gap-3 text-sm">
|
||||
{Object.entries(document.document_metadata).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-3">
|
||||
<dt className="font-medium text-muted-foreground capitalize min-w-0 flex-shrink-0">
|
||||
{key.replace(/_/g, " ")}:
|
||||
</dt>
|
||||
<dd className="text-foreground break-words">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External Link */}
|
||||
{url && (
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={(e) => handleUrlClick(e, url)}
|
||||
className="w-full py-3"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open in Browser
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chunks */}
|
||||
<div className="space-y-6" ref={chunksContainerRef}>
|
||||
<div className="mb-4">
|
||||
{/* Header row: header and button side by side */}
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<h3 className="text-base font-semibold mb-2 md:mb-0">Document Content</h3>
|
||||
{document.content && (
|
||||
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 py-2 px-3 font-medium border rounded-md bg-muted hover:bg-muted/80 transition-colors">
|
||||
<span>Summary</span>
|
||||
{summaryOpen ? (
|
||||
<ChevronUp className="h-4 w-4 transition-transform" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 transition-transform" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
{/* Expanded summary content: always full width, below the row */}
|
||||
{document.content && (
|
||||
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
|
||||
<CollapsibleContent className="pt-2 w-full">
|
||||
<div className="p-6 bg-muted/50 rounded-lg border">
|
||||
<MarkdownViewer content={document.content} />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{document.chunks.map((chunk, idx) => (
|
||||
<div
|
||||
key={chunk.id}
|
||||
ref={chunk.id === chunkId ? highlightedChunkRef : null}
|
||||
className={cn(
|
||||
"p-6 rounded-lg border transition-all duration-300",
|
||||
chunk.id === chunkId
|
||||
? "bg-primary/10 border-primary shadow-md ring-1 ring-primary/20"
|
||||
: "bg-background border-border hover:bg-muted/50 hover:border-muted-foreground/20"
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Chunk {idx + 1} of {document.chunks.length}
|
||||
</span>
|
||||
{chunk.id === chunkId && (
|
||||
<span className="text-sm font-medium text-primary bg-primary/10 px-3 py-1 rounded-full">
|
||||
Referenced Chunk
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||
<MarkdownViewer content={chunk.content} className="max-w-fit" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import type { Connector, Source } from "./types";
|
||||
|
||||
/**
|
||||
* Function to get sources for the main view
|
||||
*/
|
||||
export const getMainViewSources = (connector: Connector, initialSourcesDisplay: number) => {
|
||||
return connector.sources?.slice(0, initialSourcesDisplay);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get filtered sources for the dialog
|
||||
*/
|
||||
export const getFilteredSources = (connector: Connector, sourceFilter: string) => {
|
||||
if (!sourceFilter.trim()) {
|
||||
return connector.sources;
|
||||
}
|
||||
|
||||
const filter = sourceFilter.toLowerCase().trim();
|
||||
return connector.sources?.filter(
|
||||
(source) =>
|
||||
source.title.toLowerCase().includes(filter) ||
|
||||
source.description.toLowerCase().includes(filter)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get paginated and filtered sources for the dialog
|
||||
*/
|
||||
export const getPaginatedDialogSources = (
|
||||
connector: Connector,
|
||||
sourceFilter: string,
|
||||
expandedSources: boolean,
|
||||
sourcesPage: number,
|
||||
sourcesPerPage: number
|
||||
) => {
|
||||
const filteredSources = getFilteredSources(connector, sourceFilter);
|
||||
|
||||
if (expandedSources) {
|
||||
return filteredSources;
|
||||
}
|
||||
return filteredSources?.slice(0, sourcesPage * sourcesPerPage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get the count of sources for a connector type
|
||||
*/
|
||||
export const getSourcesCount = (connectorSources: Connector[], connectorType: string) => {
|
||||
const connector = connectorSources.find((c) => c.type === connectorType);
|
||||
return connector?.sources?.length || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get a citation source by ID
|
||||
*/
|
||||
export const getCitationSource = (
|
||||
citationId: number,
|
||||
connectorSources: Connector[]
|
||||
): Source | null => {
|
||||
for (const connector of connectorSources) {
|
||||
const source = connector.sources?.find((s) => s.id === citationId);
|
||||
if (source) {
|
||||
return {
|
||||
...source,
|
||||
connectorType: connector.type,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
// Export all components and utilities from the chat folder
|
||||
|
||||
export * from "./Citation";
|
||||
export * from "./CodeBlock";
|
||||
export * from "./ConnectorComponents";
|
||||
export * from "./ScrollUtils";
|
||||
export { default as SegmentedControl } from "./SegmentedControl";
|
||||
export * from "./SourceUtils";
|
||||
export * from "./types";
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
/**
|
||||
* Types for chat components
|
||||
*/
|
||||
|
||||
export type Source = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
connectorType?: string;
|
||||
};
|
||||
|
||||
export type Connector = {
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
sources?: Source[];
|
||||
};
|
||||
|
||||
export type StatusMessage = {
|
||||
id: number;
|
||||
message: string;
|
||||
type: "info" | "success" | "error" | "warning";
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
// Define message types to match useChat() structure
|
||||
export type MessageRole = "user" | "assistant" | "system" | "data";
|
||||
|
||||
export interface ToolInvocation {
|
||||
state: "call" | "result";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: any;
|
||||
result?: any;
|
||||
}
|
||||
|
||||
export interface ToolInvocationUIPart {
|
||||
type: "tool-invocation";
|
||||
toolInvocation: ToolInvocation;
|
||||
}
|
||||
|
||||
export type ResearchMode = "QNA";
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
|
|
@ -26,7 +24,6 @@ interface BreadcrumbItemInterface {
|
|||
export function DashboardBreadcrumb() {
|
||||
const t = useTranslations("breadcrumb");
|
||||
const pathname = usePathname();
|
||||
const { data: activeChatState } = useAtomValue(activeChatAtom);
|
||||
// Extract search space ID and chat ID from pathname
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const searchSpaceId = segments[0] === "dashboard" && segments[1] ? segments[1] : null;
|
||||
|
|
@ -98,13 +95,11 @@ export function DashboardBreadcrumb() {
|
|||
|
||||
// Map section names to more readable labels
|
||||
const sectionLabels: Record<string, string> = {
|
||||
researcher: t("researcher"),
|
||||
"new-chat": t("chat") || "Chat",
|
||||
documents: t("documents"),
|
||||
connectors: t("connectors"),
|
||||
sources: "Sources",
|
||||
podcasts: t("podcasts"),
|
||||
logs: t("logs"),
|
||||
chats: t("chats"),
|
||||
settings: t("settings"),
|
||||
editor: t("editor"),
|
||||
};
|
||||
|
|
@ -169,15 +164,15 @@ export function DashboardBreadcrumb() {
|
|||
return breadcrumbs;
|
||||
}
|
||||
|
||||
// Handle researcher sub-sections (chat IDs)
|
||||
if (section === "researcher") {
|
||||
// Use the actual chat title if available, otherwise fall back to the ID
|
||||
const chatLabel = activeChatState?.chatDetails?.title || subSection;
|
||||
// Handle new-chat sub-sections (thread IDs)
|
||||
if (section === "new-chat") {
|
||||
breadcrumbs.push({
|
||||
label: t("researcher"),
|
||||
href: `/dashboard/${segments[1]}/researcher`,
|
||||
label: t("chat") || "Chat",
|
||||
href: `/dashboard/${segments[1]}/new-chat`,
|
||||
});
|
||||
breadcrumbs.push({ label: chatLabel });
|
||||
if (subSection) {
|
||||
breadcrumbs.push({ label: `Thread ${subSection}` });
|
||||
}
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ import { makeAssistantToolUI } from "@assistant-ui/react";
|
|||
import { AlertCircleIcon, Loader2Icon, MicIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Audio } from "@/components/tool-ui/audio";
|
||||
import type { PodcastTranscriptEntry } from "@/contracts/types/podcast.types";
|
||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state";
|
||||
|
||||
/**
|
||||
|
|
@ -113,6 +112,14 @@ function AudioLoadingState({ title }: { title: string }) {
|
|||
/**
|
||||
* Podcast Player Component - Fetches audio and transcript with authentication
|
||||
*/
|
||||
/**
|
||||
* Transcript entry type for podcast transcripts
|
||||
*/
|
||||
interface PodcastTranscriptEntry {
|
||||
speaker_id: number;
|
||||
dialog: string;
|
||||
}
|
||||
|
||||
function PodcastPlayer({
|
||||
podcastId,
|
||||
title,
|
||||
|
|
@ -156,14 +163,22 @@ function PodcastPlayer({
|
|||
|
||||
try {
|
||||
// Fetch audio blob and podcast details in parallel
|
||||
const [audioBlob, podcastDetails] = await Promise.all([
|
||||
podcastsApiService.loadPodcast({
|
||||
request: { id: podcastId },
|
||||
controller,
|
||||
}),
|
||||
podcastsApiService.getPodcastById(podcastId),
|
||||
const [audioResponse, podcastDetails] = await Promise.all([
|
||||
authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
|
||||
{ method: "GET", signal: controller.signal }
|
||||
),
|
||||
baseApiService.get<{ podcast_transcript?: PodcastTranscriptEntry[] }>(
|
||||
`/api/v1/podcasts/${podcastId}`
|
||||
),
|
||||
]);
|
||||
|
||||
if (!audioResponse.ok) {
|
||||
throw new Error(`Failed to load audio: ${audioResponse.status}`);
|
||||
}
|
||||
|
||||
const audioBlob = await audioResponse.blob();
|
||||
|
||||
// Create object URL from blob
|
||||
const objectUrl = URL.createObjectURL(audioBlob);
|
||||
objectUrlRef.current = objectUrl;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue