mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-27 19:25:15 +02:00
Initial formatting using biome
This commit is contained in:
parent
4c8ff48155
commit
758603b275
156 changed files with 23825 additions and 29508 deletions
|
|
@ -2,159 +2,151 @@
|
|||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Manrope } from "next/font/google";
|
||||
import React, {
|
||||
useRef,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useMemo
|
||||
} from "react";
|
||||
import React, { useRef, useEffect, useReducer, useMemo } from "react";
|
||||
import { RoughNotation, RoughNotationGroup } from "react-rough-notation";
|
||||
import { useInView } from "framer-motion";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
|
||||
// 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"
|
||||
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
|
||||
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",
|
||||
},
|
||||
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;
|
||||
shouldShowHighlight: boolean;
|
||||
layoutStable: boolean;
|
||||
}
|
||||
|
||||
type HighlightAction =
|
||||
| { type: "SIDEBAR_CHANGED" }
|
||||
| { type: "LAYOUT_STABILIZED" }
|
||||
| { type: "SHOW_HIGHLIGHT" }
|
||||
| { type: "HIDE_HIGHLIGHT" };
|
||||
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 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,
|
||||
shouldShowHighlight: false,
|
||||
layoutStable: true,
|
||||
};
|
||||
|
||||
export function AnimatedEmptyState() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref);
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
|
||||
highlightReducer,
|
||||
initialState
|
||||
);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref);
|
||||
const { state: sidebarState } = useSidebar();
|
||||
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,
|
||||
), []);
|
||||
// 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",
|
||||
[]);
|
||||
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" });
|
||||
// Handle sidebar state changes
|
||||
useEffect(() => {
|
||||
dispatch({ type: "SIDEBAR_CHANGED" });
|
||||
|
||||
const stabilizeTimer = setTimeout(() => {
|
||||
dispatch({ type: "LAYOUT_STABILIZED" });
|
||||
}, TIMING.SIDEBAR_TRANSITION);
|
||||
const stabilizeTimer = setTimeout(() => {
|
||||
dispatch({ type: "LAYOUT_STABILIZED" });
|
||||
}, TIMING.SIDEBAR_TRANSITION);
|
||||
|
||||
return () => clearTimeout(stabilizeTimer);
|
||||
}, [sidebarState]);
|
||||
return () => clearTimeout(stabilizeTimer);
|
||||
}, [sidebarState]);
|
||||
|
||||
// Handle highlight visibility based on layout stability and viewport visibility
|
||||
useEffect(() => {
|
||||
if (!layoutStable || !isInView) {
|
||||
dispatch({ type: "HIDE_HIGHLIGHT" });
|
||||
return;
|
||||
}
|
||||
// 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);
|
||||
const showTimer = setTimeout(() => {
|
||||
dispatch({ type: "SHOW_HIGHLIGHT" });
|
||||
}, TIMING.LAYOUT_SETTLE);
|
||||
|
||||
return () => clearTimeout(showTimer);
|
||||
}, [layoutStable, isInView]);
|
||||
return () => clearTimeout(showTimer);
|
||||
}, [layoutStable, isInView]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex-1 flex items-center justify-center w-full min-h-[400px]"
|
||||
>
|
||||
<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>
|
||||
return (
|
||||
<div ref={ref} className="flex-1 flex items-center justify-center w-full min-h-[400px]">
|
||||
<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>
|
||||
);
|
||||
<p className={paragraphClassName}>
|
||||
<RoughNotation {...ANIMATION_CONFIG.UNDERLINE}>Let's Start Surfing</RoughNotation>{" "}
|
||||
through your knowledge base.
|
||||
</p>
|
||||
</RoughNotationGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import type React from "react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
export const CitationDisplay: React.FC<{ index: number; node: any }> = ({
|
||||
index,
|
||||
node,
|
||||
}) => {
|
||||
export const CitationDisplay: React.FC<{ index: number; node: any }> = ({ index, node }) => {
|
||||
const truncateText = (text: string, maxLength: number = 200) => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + "...";
|
||||
|
|
|
|||
|
|
@ -1,45 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { SuggestedQuestions } from "@llamaindex/chat-ui/widgets";
|
||||
import { getAnnotationData, Message, useChatUI } from "@llamaindex/chat-ui";
|
||||
import { getAnnotationData, type Message, useChatUI } from "@llamaindex/chat-ui";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
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();
|
||||
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 <></>;
|
||||
}
|
||||
if (annotations.length !== 1 || annotations[0].length === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,14 +21,14 @@ import {
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Suspense, useState, useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDocuments, Document } from "@/hooks/use-documents";
|
||||
import { useDocuments, type Document } from "@/hooks/use-documents";
|
||||
import { DocumentsDataTable } from "@/components/chat/DocumentsDataTable";
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
getConnectorIcon,
|
||||
ConnectorButton as ConnectorButtonComponent,
|
||||
} from "@/components/chat/ConnectorComponents";
|
||||
import { ResearchMode } from "@/components/chat";
|
||||
import type { ResearchMode } from "@/components/chat";
|
||||
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||
import React from "react";
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ const DocumentSelector = React.memo(
|
|||
|
||||
const { documents, loading, isLoaded, fetchDocuments } = useDocuments(
|
||||
Number(search_space_id),
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
|
|
@ -55,24 +55,21 @@ const DocumentSelector = React.memo(
|
|||
fetchDocuments();
|
||||
}
|
||||
},
|
||||
[fetchDocuments, isLoaded],
|
||||
[fetchDocuments, isLoaded]
|
||||
);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(documents: Document[]) => {
|
||||
onSelectionChange?.(documents);
|
||||
},
|
||||
[onSelectionChange],
|
||||
[onSelectionChange]
|
||||
);
|
||||
|
||||
const handleDone = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const selectedCount = React.useMemo(
|
||||
() => selectedDocuments.length,
|
||||
[selectedDocuments.length],
|
||||
);
|
||||
const selectedCount = React.useMemo(() => selectedDocuments.length, [selectedDocuments.length]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
|
|
@ -90,9 +87,7 @@ const DocumentSelector = React.memo(
|
|||
<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">
|
||||
<DialogTitle className="text-lg md:text-xl">
|
||||
Select Documents
|
||||
</DialogTitle>
|
||||
<DialogTitle className="text-lg md:text-xl">Select Documents</DialogTitle>
|
||||
<DialogDescription className="mt-1 text-sm">
|
||||
Choose documents to include in your research context
|
||||
</DialogDescription>
|
||||
|
|
@ -103,9 +98,7 @@ const DocumentSelector = React.memo(
|
|||
<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>
|
||||
<p className="text-sm text-muted-foreground">Loading documents...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoaded ? (
|
||||
|
|
@ -121,7 +114,7 @@ const DocumentSelector = React.memo(
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
DocumentSelector.displayName = "DocumentSelector";
|
||||
|
|
@ -146,7 +139,7 @@ const ConnectorSelector = React.memo(
|
|||
fetchConnectors();
|
||||
}
|
||||
},
|
||||
[fetchConnectors, isLoaded],
|
||||
[fetchConnectors, isLoaded]
|
||||
);
|
||||
|
||||
const handleConnectorToggle = useCallback(
|
||||
|
|
@ -157,7 +150,7 @@ const ConnectorSelector = React.memo(
|
|||
: [...selectedConnectors, connectorType];
|
||||
onSelectionChange?.(newSelection);
|
||||
},
|
||||
[selectedConnectors, onSelectionChange],
|
||||
[selectedConnectors, onSelectionChange]
|
||||
);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
|
|
@ -210,9 +203,7 @@ const ConnectorSelector = React.memo(
|
|||
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-full bg-muted">
|
||||
{getConnectorIcon(connector.type)}
|
||||
</div>
|
||||
<span className="flex-1 text-sm font-medium">
|
||||
{connector.name}
|
||||
</span>
|
||||
<span className="flex-1 text-sm font-medium">{connector.name}</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-primary" />}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -231,7 +222,7 @@ const ConnectorSelector = React.memo(
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
ConnectorSelector.displayName = "ConnectorSelector";
|
||||
|
|
@ -254,9 +245,7 @@ const SearchModeSelector = React.memo(
|
|||
|
||||
return (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<span className="text-xs text-muted-foreground hidden sm:block">
|
||||
Scope:
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground hidden sm:block">Scope:</span>
|
||||
<div className="flex rounded-md border border-border overflow-hidden">
|
||||
<Button
|
||||
variant={searchMode === "DOCUMENTS" ? "default" : "ghost"}
|
||||
|
|
@ -278,7 +267,7 @@ const SearchModeSelector = React.memo(
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
SearchModeSelector.displayName = "SearchModeSelector";
|
||||
|
|
@ -295,7 +284,7 @@ const ResearchModeSelector = React.memo(
|
|||
(value: string) => {
|
||||
onResearchModeChange?.(value as ResearchMode);
|
||||
},
|
||||
[onResearchModeChange],
|
||||
[onResearchModeChange]
|
||||
);
|
||||
|
||||
// Memoize mode options to prevent recreation
|
||||
|
|
@ -318,14 +307,12 @@ const ResearchModeSelector = React.memo(
|
|||
shortLabel: "Deeper",
|
||||
},
|
||||
],
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<span className="text-xs text-muted-foreground hidden sm:block">
|
||||
Mode:
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground hidden sm:block">Mode:</span>
|
||||
<Select value={researchMode} onValueChange={handleValueChange}>
|
||||
<SelectTrigger className="w-auto min-w-[80px] sm:min-w-[120px] h-8 text-xs border-border bg-background hover:bg-muted/50 transition-colors duration-200 focus:ring-2 focus:ring-primary/20">
|
||||
<SelectValue placeholder="Mode" className="text-xs" />
|
||||
|
|
@ -348,27 +335,21 @@ const ResearchModeSelector = React.memo(
|
|||
</Select>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
ResearchModeSelector.displayName = "ResearchModeSelector";
|
||||
|
||||
const LLMSelector = React.memo(() => {
|
||||
const { llmConfigs, loading: llmLoading, error } = useLLMConfigs();
|
||||
const {
|
||||
preferences,
|
||||
updatePreferences,
|
||||
loading: preferencesLoading,
|
||||
} = useLLMPreferences();
|
||||
const { preferences, updatePreferences, loading: preferencesLoading } = useLLMPreferences();
|
||||
|
||||
const isLoading = llmLoading || preferencesLoading;
|
||||
|
||||
// Memoize the selected config to avoid repeated lookups
|
||||
const selectedConfig = React.useMemo(() => {
|
||||
if (!preferences.fast_llm_id || !llmConfigs.length) return null;
|
||||
return (
|
||||
llmConfigs.find((config) => config.id === preferences.fast_llm_id) || null
|
||||
);
|
||||
return llmConfigs.find((config) => config.id === preferences.fast_llm_id) || null;
|
||||
}, [preferences.fast_llm_id, llmConfigs]);
|
||||
|
||||
// Memoize the display value for the trigger
|
||||
|
|
@ -390,7 +371,7 @@ const LLMSelector = React.memo(() => {
|
|||
const llmId = value ? parseInt(value, 10) : undefined;
|
||||
updatePreferences({ fast_llm_id: llmId });
|
||||
},
|
||||
[updatePreferences],
|
||||
[updatePreferences]
|
||||
);
|
||||
|
||||
// Loading skeleton
|
||||
|
|
@ -432,9 +413,7 @@ const LLMSelector = React.memo(() => {
|
|||
<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>
|
||||
)}
|
||||
{displayValue || <span className="text-muted-foreground">Select LLM</span>}
|
||||
</SelectValue>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
|
|
@ -452,9 +431,7 @@ const LLMSelector = React.memo(() => {
|
|||
<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>
|
||||
<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>
|
||||
|
|
@ -482,13 +459,8 @@ const LLMSelector = React.memo(() => {
|
|||
</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"
|
||||
>
|
||||
<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>
|
||||
|
|
@ -537,10 +509,8 @@ const CustomChatInputOptions = React.memo(
|
|||
}) => {
|
||||
// Memoize the loading fallback to prevent recreation
|
||||
const loadingFallback = React.useMemo(
|
||||
() => (
|
||||
<div className="h-8 min-w-[100px] animate-pulse bg-muted rounded-md" />
|
||||
),
|
||||
[],
|
||||
() => <div className="h-8 min-w-[100px] animate-pulse bg-muted rounded-md" />,
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -557,10 +527,7 @@ const CustomChatInputOptions = React.memo(
|
|||
selectedConnectors={selectedConnectors}
|
||||
/>
|
||||
</Suspense>
|
||||
<SearchModeSelector
|
||||
searchMode={searchMode}
|
||||
onSearchModeChange={onSearchModeChange}
|
||||
/>
|
||||
<SearchModeSelector searchMode={searchMode} onSearchModeChange={onSearchModeChange} />
|
||||
<ResearchModeSelector
|
||||
researchMode={researchMode}
|
||||
onResearchModeChange={onResearchModeChange}
|
||||
|
|
@ -568,7 +535,7 @@ const CustomChatInputOptions = React.memo(
|
|||
<LLMSelector />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
CustomChatInputOptions.displayName = "CustomChatInputOptions";
|
||||
|
|
@ -611,7 +578,7 @@ export const ChatInputUI = React.memo(
|
|||
/>
|
||||
</ChatInput>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
ChatInputUI.displayName = "ChatInputUI";
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
ChatSection as LlamaIndexChatSection,
|
||||
ChatHandler,
|
||||
} from "@llamaindex/chat-ui";
|
||||
import { Document } from "@/hooks/use-documents";
|
||||
import { ChatSection as LlamaIndexChatSection, type ChatHandler } from "@llamaindex/chat-ui";
|
||||
import type { Document } from "@/hooks/use-documents";
|
||||
import { ChatInputUI } from "@/components/chat/ChatInputGroup";
|
||||
import { ResearchMode } from "@/components/chat";
|
||||
import type { ResearchMode } from "@/components/chat";
|
||||
import { ChatMessagesUI } from "@/components/chat/ChatMessages";
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
import React from "react";
|
||||
import {
|
||||
ChatMessage as LlamaIndexChatMessage,
|
||||
ChatMessages as LlamaIndexChatMessages,
|
||||
Message,
|
||||
useChatUI,
|
||||
ChatMessage as LlamaIndexChatMessage,
|
||||
ChatMessages as LlamaIndexChatMessages,
|
||||
type Message,
|
||||
useChatUI,
|
||||
} from "@llamaindex/chat-ui";
|
||||
import TerminalDisplay from "@/components/chat/ChatTerminal";
|
||||
import ChatSourcesDisplay from "@/components/chat/ChatSources";
|
||||
|
|
@ -14,74 +14,60 @@ import { ChatFurtherQuestions } from "@/components/chat/ChatFurtherQuestions";
|
|||
import { AnimatedEmptyState } from "@/components/chat/AnimatedEmptyState";
|
||||
import { languageRenderers } from "@/components/chat/CodeBlock";
|
||||
|
||||
|
||||
|
||||
export function ChatMessagesUI() {
|
||||
const { messages } = useChatUI();
|
||||
const { messages } = useChatUI();
|
||||
|
||||
return (
|
||||
<LlamaIndexChatMessages className="flex-1">
|
||||
<LlamaIndexChatMessages.Empty>
|
||||
<AnimatedEmptyState />
|
||||
</LlamaIndexChatMessages.Empty>
|
||||
<LlamaIndexChatMessages.List className="p-4">
|
||||
{messages.map((message, index) => (
|
||||
<ChatMessageUI
|
||||
key={`Message-${index}`}
|
||||
message={message}
|
||||
isLast={index === messages.length - 1}
|
||||
/>
|
||||
))}
|
||||
</LlamaIndexChatMessages.List>
|
||||
<LlamaIndexChatMessages.Loading />
|
||||
</LlamaIndexChatMessages>
|
||||
);
|
||||
return (
|
||||
<LlamaIndexChatMessages className="flex-1">
|
||||
<LlamaIndexChatMessages.Empty>
|
||||
<AnimatedEmptyState />
|
||||
</LlamaIndexChatMessages.Empty>
|
||||
<LlamaIndexChatMessages.List className="p-4">
|
||||
{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 = React.useRef<HTMLDivElement>(null);
|
||||
function ChatMessageUI({ message, isLast }: { message: Message; isLast: boolean }) {
|
||||
const bottomRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLast && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [message]);
|
||||
React.useEffect(() => {
|
||||
if (isLast && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
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>
|
||||
);
|
||||
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,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { getAnnotationData, Message } from "@llamaindex/chat-ui";
|
||||
import { getAnnotationData, type Message } from "@llamaindex/chat-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -11,13 +11,7 @@ import {
|
|||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ExternalLink, FileText, Globe } from "lucide-react";
|
||||
import { IconBrandGithub } from "@tabler/icons-react";
|
||||
|
|
@ -113,12 +107,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
|||
const allNodes: SourceNode[] = [];
|
||||
|
||||
annotations.forEach((item) => {
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"nodes" in item &&
|
||||
Array.isArray(item.nodes)
|
||||
) {
|
||||
if (item && typeof item === "object" && "nodes" in item && Array.isArray(item.nodes)) {
|
||||
allNodes.push(...item.nodes);
|
||||
}
|
||||
});
|
||||
|
|
@ -133,7 +122,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
|||
acc[sourceType].push(node);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, SourceNode[]>,
|
||||
{} as Record<string, SourceNode[]>
|
||||
);
|
||||
|
||||
// Convert grouped nodes to SourceGroup format
|
||||
|
|
@ -159,10 +148,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
|||
return null;
|
||||
}
|
||||
|
||||
const totalSources = sourceGroups.reduce(
|
||||
(acc, group) => acc + group.sources.length,
|
||||
0,
|
||||
);
|
||||
const totalSources = sourceGroups.reduce((acc, group) => acc + group.sources.length, 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
|
|
@ -176,10 +162,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
|||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Sources</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs
|
||||
defaultValue={sourceGroups[0]?.type}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<Tabs defaultValue={sourceGroups[0]?.type} className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-shrink-0 w-full overflow-x-auto">
|
||||
<TabsList className="flex w-max min-w-full">
|
||||
{sourceGroups.map((group) => (
|
||||
|
|
@ -189,13 +172,8 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
|||
className="flex items-center gap-2 whitespace-nowrap px-3 md:px-4"
|
||||
>
|
||||
{getSourceIcon(group.type)}
|
||||
<span className="truncate max-w-[100px] md:max-w-none">
|
||||
{group.name}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 h-5 text-xs flex-shrink-0"
|
||||
>
|
||||
<span className="truncate max-w-[100px] md:max-w-none">{group.name}</span>
|
||||
<Badge variant="secondary" className="ml-1 h-5 text-xs flex-shrink-0">
|
||||
{group.sources.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
|
|
@ -203,11 +181,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
|||
</TabsList>
|
||||
</div>
|
||||
{sourceGroups.map((group) => (
|
||||
<TabsContent
|
||||
key={group.type}
|
||||
value={group.type}
|
||||
className="flex-1 min-h-0 mt-4"
|
||||
>
|
||||
<TabsContent key={group.type} value={group.type} className="flex-1 min-h-0 mt-4">
|
||||
<div className="h-full overflow-y-auto pr-2">
|
||||
<div className="space-y-3">
|
||||
{group.sources.map((source) => (
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { getAnnotationData, Message } from "@llamaindex/chat-ui";
|
||||
import { getAnnotationData, type Message } from "@llamaindex/chat-ui";
|
||||
|
||||
export default function TerminalDisplay({
|
||||
message,
|
||||
open,
|
||||
}: {
|
||||
message: Message;
|
||||
open: boolean;
|
||||
}) {
|
||||
export default function TerminalDisplay({ message, open }: { message: Message; open: boolean }) {
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(!open);
|
||||
|
||||
const bottomRef = React.useRef<HTMLDivElement>(null);
|
||||
|
|
@ -57,12 +51,7 @@ export default function TerminalDisplay({
|
|||
</div>
|
||||
<div className="text-gray-400">
|
||||
{isCollapsed ? (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
|
@ -71,12 +60,7 @@ export default function TerminalDisplay({
|
|||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
|
@ -90,10 +74,7 @@ export default function TerminalDisplay({
|
|||
|
||||
{/* Terminal Content */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
ref={bottomRef}
|
||||
className="h-64 overflow-y-auto p-4 space-y-1 bg-gray-900"
|
||||
>
|
||||
<div ref={bottomRef} className="h-64 overflow-y-auto p-4 space-y-1 bg-gray-900">
|
||||
{events.map((event, index) => (
|
||||
<div key={`${event.id}-${index}`} className="text-green-400">
|
||||
<span className="text-blue-400">$</span>
|
||||
|
|
@ -104,9 +85,7 @@ export default function TerminalDisplay({
|
|||
</div>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<div className="text-gray-500 italic">
|
||||
No agent events to display...
|
||||
</div>
|
||||
<div className="text-gray-500 italic">No agent events to display...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,116 +1,119 @@
|
|||
import React, { useState } from 'react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import React, { useState } from "react";
|
||||
import { ExternalLink } from "lucide-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 { Source } from './types';
|
||||
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;
|
||||
citationId: number;
|
||||
citationText: string;
|
||||
position: number;
|
||||
source: Source | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Citation component to handle individual citations
|
||||
*/
|
||||
export const Citation = React.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>
|
||||
);
|
||||
});
|
||||
export const Citation = React.memo(
|
||||
({ citationId, citationText, position, source }: CitationProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const citationKey = `citation-${citationId}-${position}`;
|
||||
|
||||
Citation.displayName = 'Citation';
|
||||
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;
|
||||
let position = 0;
|
||||
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;
|
||||
let position = 0;
|
||||
|
||||
while ((match = citationRegex.exec(text)) !== 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++;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last citation
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.substring(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
while ((match = citationRegex.exec(text)) !== 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++;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last citation
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.substring(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@
|
|||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import {
|
||||
oneLight,
|
||||
oneDark,
|
||||
} from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||
import { oneLight, oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
|
|
@ -13,182 +10,202 @@ import { useTheme } from "next-themes";
|
|||
const COPY_TIMEOUT = 2000;
|
||||
|
||||
const BASE_CUSTOM_STYLE = {
|
||||
margin: 0,
|
||||
borderRadius: "0.375rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1.5rem",
|
||||
border: "none",
|
||||
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",
|
||||
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)",
|
||||
},
|
||||
className: "font-mono",
|
||||
style: {
|
||||
border: "none",
|
||||
background: "var(--syntax-bg)",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// TypeScript interfaces
|
||||
interface CodeBlockProps {
|
||||
children: string;
|
||||
language: string;
|
||||
children: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
interface LanguageRenderer {
|
||||
(props: { code: string }): React.JSX.Element;
|
||||
}
|
||||
type LanguageRenderer = (props: { code: string }) => React.JSX.Element
|
||||
|
||||
interface SyntaxStyle {
|
||||
[key: string]: React.CSSProperties;
|
||||
[key: string]: React.CSSProperties;
|
||||
}
|
||||
|
||||
// Memoized fallback component for SSR/hydration
|
||||
const FallbackCodeBlock = React.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>
|
||||
<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 = React.memo<CodeBlockProps>(({
|
||||
children,
|
||||
language,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { resolvedTheme, theme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
export const CodeBlock = React.memo<CodeBlockProps>(({ children, language }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { resolvedTheme, theme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Prevent hydration issues
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
// Prevent hydration issues
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Memoize theme detection
|
||||
const isDarkTheme = useMemo(() =>
|
||||
mounted && (resolvedTheme === "dark" || theme === "dark"),
|
||||
[mounted, resolvedTheme, theme]
|
||||
);
|
||||
// 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 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 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)",
|
||||
}), []);
|
||||
// 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 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,
|
||||
}), []);
|
||||
// Memoized line props with style
|
||||
const lineProps = useMemo(
|
||||
() => ({
|
||||
style: LINE_PROPS_STYLE,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Early return for non-mounted state
|
||||
if (!mounted) {
|
||||
return <FallbackCodeBlock>{children}</FallbackCodeBlock>;
|
||||
}
|
||||
// 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>
|
||||
);
|
||||
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;
|
||||
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"
|
||||
"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)])
|
||||
);
|
||||
export const languageRenderers: Record<string, LanguageRenderer> = Object.fromEntries(
|
||||
SUPPORTED_LANGUAGES.map((lang) => [lang, createLanguageRenderer(lang)])
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,302 +1,289 @@
|
|||
import React from "react";
|
||||
import type React from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Search,
|
||||
Globe,
|
||||
Sparkles,
|
||||
Microscope,
|
||||
Telescope,
|
||||
File,
|
||||
Link,
|
||||
Webhook,
|
||||
MessageCircle,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Search,
|
||||
Globe,
|
||||
Sparkles,
|
||||
Microscope,
|
||||
Telescope,
|
||||
File,
|
||||
Link,
|
||||
Webhook,
|
||||
MessageCircle,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
IconBrandNotion,
|
||||
IconBrandSlack,
|
||||
IconBrandYoutube,
|
||||
IconBrandGithub,
|
||||
IconLayoutKanban,
|
||||
IconLinkPlus,
|
||||
IconBrandDiscord,
|
||||
IconTicket,
|
||||
IconBrandNotion,
|
||||
IconBrandSlack,
|
||||
IconBrandYoutube,
|
||||
IconBrandGithub,
|
||||
IconLayoutKanban,
|
||||
IconLinkPlus,
|
||||
IconBrandDiscord,
|
||||
IconTicket,
|
||||
} from "@tabler/icons-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Connector, ResearchMode } from "./types";
|
||||
import type { Connector, ResearchMode } from "./types";
|
||||
|
||||
// Helper function to get connector icon
|
||||
export const getConnectorIcon = (connectorType: string) => {
|
||||
const iconProps = { className: "h-4 w-4" };
|
||||
const iconProps = { className: "h-4 w-4" };
|
||||
|
||||
switch (connectorType) {
|
||||
case "LINKUP_API":
|
||||
return <IconLinkPlus {...iconProps} />;
|
||||
case "LINEAR_CONNECTOR":
|
||||
return <IconLayoutKanban {...iconProps} />;
|
||||
case "GITHUB_CONNECTOR":
|
||||
return <IconBrandGithub {...iconProps} />;
|
||||
case "YOUTUBE_VIDEO":
|
||||
return <IconBrandYoutube {...iconProps} />;
|
||||
case "CRAWLED_URL":
|
||||
return <Globe {...iconProps} />;
|
||||
case "FILE":
|
||||
return <File {...iconProps} />;
|
||||
case "EXTENSION":
|
||||
return <Webhook {...iconProps} />;
|
||||
case "SERPER_API":
|
||||
case "TAVILY_API":
|
||||
return <Link {...iconProps} />;
|
||||
case "SLACK_CONNECTOR":
|
||||
return <IconBrandSlack {...iconProps} />;
|
||||
case "NOTION_CONNECTOR":
|
||||
return <IconBrandNotion {...iconProps} />;
|
||||
case "DISCORD_CONNECTOR":
|
||||
return <IconBrandDiscord {...iconProps} />;
|
||||
case "JIRA_CONNECTOR":
|
||||
return <IconTicket {...iconProps} />;
|
||||
case "DEEP":
|
||||
return <Sparkles {...iconProps} />;
|
||||
case "DEEPER":
|
||||
return <Microscope {...iconProps} />;
|
||||
case "DEEPEST":
|
||||
return <Telescope {...iconProps} />;
|
||||
default:
|
||||
return <Search {...iconProps} />;
|
||||
}
|
||||
switch (connectorType) {
|
||||
case "LINKUP_API":
|
||||
return <IconLinkPlus {...iconProps} />;
|
||||
case "LINEAR_CONNECTOR":
|
||||
return <IconLayoutKanban {...iconProps} />;
|
||||
case "GITHUB_CONNECTOR":
|
||||
return <IconBrandGithub {...iconProps} />;
|
||||
case "YOUTUBE_VIDEO":
|
||||
return <IconBrandYoutube {...iconProps} />;
|
||||
case "CRAWLED_URL":
|
||||
return <Globe {...iconProps} />;
|
||||
case "FILE":
|
||||
return <File {...iconProps} />;
|
||||
case "EXTENSION":
|
||||
return <Webhook {...iconProps} />;
|
||||
case "SERPER_API":
|
||||
case "TAVILY_API":
|
||||
return <Link {...iconProps} />;
|
||||
case "SLACK_CONNECTOR":
|
||||
return <IconBrandSlack {...iconProps} />;
|
||||
case "NOTION_CONNECTOR":
|
||||
return <IconBrandNotion {...iconProps} />;
|
||||
case "DISCORD_CONNECTOR":
|
||||
return <IconBrandDiscord {...iconProps} />;
|
||||
case "JIRA_CONNECTOR":
|
||||
return <IconTicket {...iconProps} />;
|
||||
case "DEEP":
|
||||
return <Sparkles {...iconProps} />;
|
||||
case "DEEPER":
|
||||
return <Microscope {...iconProps} />;
|
||||
case "DEEPEST":
|
||||
return <Telescope {...iconProps} />;
|
||||
default:
|
||||
return <Search {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const researcherOptions: {
|
||||
value: ResearchMode;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
value: ResearchMode;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
value: "QNA",
|
||||
label: "Q/A",
|
||||
icon: getConnectorIcon("GENERAL"),
|
||||
},
|
||||
{
|
||||
value: "REPORT_GENERAL",
|
||||
label: "General",
|
||||
icon: getConnectorIcon("GENERAL"),
|
||||
},
|
||||
{
|
||||
value: "REPORT_DEEP",
|
||||
label: "Deep",
|
||||
icon: getConnectorIcon("DEEP"),
|
||||
},
|
||||
{
|
||||
value: "REPORT_DEEPER",
|
||||
label: "Deeper",
|
||||
icon: getConnectorIcon("DEEPER"),
|
||||
},
|
||||
{
|
||||
value: "QNA",
|
||||
label: "Q/A",
|
||||
icon: getConnectorIcon("GENERAL"),
|
||||
},
|
||||
{
|
||||
value: "REPORT_GENERAL",
|
||||
label: "General",
|
||||
icon: getConnectorIcon("GENERAL"),
|
||||
},
|
||||
{
|
||||
value: "REPORT_DEEP",
|
||||
label: "Deep",
|
||||
icon: getConnectorIcon("DEEP"),
|
||||
},
|
||||
{
|
||||
value: "REPORT_DEEPER",
|
||||
label: "Deeper",
|
||||
icon: getConnectorIcon("DEEPER"),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 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>
|
||||
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>
|
||||
<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[];
|
||||
selectedConnectors: string[];
|
||||
onClick: () => void;
|
||||
connectorSources: Connector[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Button that displays selected connectors and opens connector selection dialog
|
||||
*/
|
||||
export const ConnectorButton = ({
|
||||
selectedConnectors,
|
||||
onClick,
|
||||
connectorSources,
|
||||
selectedConnectors,
|
||||
onClick,
|
||||
connectorSources,
|
||||
}: ConnectorButtonProps) => {
|
||||
const totalConnectors = connectorSources.length;
|
||||
const selectedCount = selectedConnectors.length;
|
||||
const progressPercentage = (selectedCount / totalConnectors) * 100;
|
||||
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 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`;
|
||||
};
|
||||
// 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 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} />
|
||||
))}
|
||||
// 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>
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
{/* 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",
|
||||
}}
|
||||
/>
|
||||
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>
|
||||
);
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
// New component for Research Mode Control with Q/A and Report toggle
|
||||
type ResearchModeControlProps = {
|
||||
value: ResearchMode;
|
||||
onChange: (value: ResearchMode) => void;
|
||||
value: ResearchMode;
|
||||
onChange: (value: ResearchMode) => void;
|
||||
};
|
||||
|
||||
export const ResearchModeControl = ({
|
||||
value,
|
||||
onChange,
|
||||
}: ResearchModeControlProps) => {
|
||||
// Determine if we're in Q/A mode or Report mode
|
||||
const isQnaMode = value === "QNA";
|
||||
const isReportMode = value.startsWith("REPORT_");
|
||||
export const ResearchModeControl = ({ value, onChange }: ResearchModeControlProps) => {
|
||||
// Determine if we're in Q/A mode or Report mode
|
||||
const isQnaMode = value === "QNA";
|
||||
const isReportMode = value.startsWith("REPORT_");
|
||||
|
||||
// Get the current report sub-mode
|
||||
const getCurrentReportMode = () => {
|
||||
if (!isReportMode) return "GENERAL";
|
||||
return value.replace("REPORT_", "") as "GENERAL" | "DEEP" | "DEEPER";
|
||||
};
|
||||
// Get the current report sub-mode
|
||||
const getCurrentReportMode = () => {
|
||||
if (!isReportMode) return "GENERAL";
|
||||
return value.replace("REPORT_", "") as "GENERAL" | "DEEP" | "DEEPER";
|
||||
};
|
||||
|
||||
const reportSubOptions = [
|
||||
{ value: "GENERAL", label: "General", icon: getConnectorIcon("GENERAL") },
|
||||
{ value: "DEEP", label: "Deep", icon: getConnectorIcon("DEEP") },
|
||||
{ value: "DEEPER", label: "Deeper", icon: getConnectorIcon("DEEPER") },
|
||||
];
|
||||
const reportSubOptions = [
|
||||
{ value: "GENERAL", label: "General", icon: getConnectorIcon("GENERAL") },
|
||||
{ value: "DEEP", label: "Deep", icon: getConnectorIcon("DEEP") },
|
||||
{ value: "DEEPER", label: "Deeper", icon: getConnectorIcon("DEEPER") },
|
||||
];
|
||||
|
||||
const handleModeToggle = (mode: "QNA" | "REPORT") => {
|
||||
if (mode === "QNA") {
|
||||
onChange("QNA");
|
||||
} else {
|
||||
// Default to GENERAL for Report mode
|
||||
onChange("REPORT_GENERAL");
|
||||
}
|
||||
};
|
||||
const handleModeToggle = (mode: "QNA" | "REPORT") => {
|
||||
if (mode === "QNA") {
|
||||
onChange("QNA");
|
||||
} else {
|
||||
// Default to GENERAL for Report mode
|
||||
onChange("REPORT_GENERAL");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReportSubModeChange = (subMode: string) => {
|
||||
onChange(`REPORT_${subMode}` as ResearchMode);
|
||||
};
|
||||
const handleReportSubModeChange = (subMode: string) => {
|
||||
onChange(`REPORT_${subMode}` as ResearchMode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Main Q/A vs Report Toggle */}
|
||||
<div className="flex h-8 rounded-md border border-border overflow-hidden">
|
||||
<button
|
||||
className={`flex h-full items-center gap-1 px-3 text-xs font-medium transition-colors whitespace-nowrap ${
|
||||
isQnaMode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => handleModeToggle("QNA")}
|
||||
aria-pressed={isQnaMode}
|
||||
>
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
<span>Q/A</span>
|
||||
</button>
|
||||
<button
|
||||
className={`flex h-full items-center gap-1 px-3 text-xs font-medium transition-colors whitespace-nowrap ${
|
||||
isReportMode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => handleModeToggle("REPORT")}
|
||||
aria-pressed={isReportMode}
|
||||
>
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>Report</span>
|
||||
</button>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Main Q/A vs Report Toggle */}
|
||||
<div className="flex h-8 rounded-md border border-border overflow-hidden">
|
||||
<button
|
||||
className={`flex h-full items-center gap-1 px-3 text-xs font-medium transition-colors whitespace-nowrap ${
|
||||
isQnaMode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => handleModeToggle("QNA")}
|
||||
aria-pressed={isQnaMode}
|
||||
>
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
<span>Q/A</span>
|
||||
</button>
|
||||
<button
|
||||
className={`flex h-full items-center gap-1 px-3 text-xs font-medium transition-colors whitespace-nowrap ${
|
||||
isReportMode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => handleModeToggle("REPORT")}
|
||||
aria-pressed={isReportMode}
|
||||
>
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>Report</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Report Sub-options (only show when in Report mode) */}
|
||||
{isReportMode && (
|
||||
<div className="flex h-8 rounded-md border border-border overflow-hidden">
|
||||
{reportSubOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={`flex h-full items-center gap-1 px-2 text-xs font-medium transition-colors whitespace-nowrap ${
|
||||
getCurrentReportMode() === option.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => handleReportSubModeChange(option.value)}
|
||||
aria-pressed={getCurrentReportMode() === option.value}
|
||||
>
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* Report Sub-options (only show when in Report mode) */}
|
||||
{isReportMode && (
|
||||
<div className="flex h-8 rounded-md border border-border overflow-hidden">
|
||||
{reportSubOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={`flex h-full items-center gap-1 px-2 text-xs font-medium transition-colors whitespace-nowrap ${
|
||||
getCurrentReportMode() === option.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => handleReportSubModeChange(option.value)}
|
||||
aria-pressed={getCurrentReportMode() === option.value}
|
||||
>
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,501 +2,463 @@
|
|||
|
||||
import * as React from "react";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { ArrowUpDown, Calendar, FileText, Search } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Document, DocumentType } from "@/hooks/use-documents";
|
||||
import type { Document, DocumentType } from "@/hooks/use-documents";
|
||||
|
||||
interface DocumentsDataTableProps {
|
||||
documents: Document[];
|
||||
onSelectionChange: (documents: Document[]) => void;
|
||||
onDone: () => void;
|
||||
initialSelectedDocuments?: Document[];
|
||||
documents: Document[];
|
||||
onSelectionChange: (documents: Document[]) => void;
|
||||
onDone: () => void;
|
||||
initialSelectedDocuments?: Document[];
|
||||
}
|
||||
|
||||
const DOCUMENT_TYPES: (DocumentType | "ALL")[] = [
|
||||
"ALL",
|
||||
"FILE",
|
||||
"EXTENSION",
|
||||
"CRAWLED_URL",
|
||||
"YOUTUBE_VIDEO",
|
||||
"SLACK_CONNECTOR",
|
||||
"NOTION_CONNECTOR",
|
||||
"GITHUB_CONNECTOR",
|
||||
"LINEAR_CONNECTOR",
|
||||
"DISCORD_CONNECTOR",
|
||||
"JIRA_CONNECTOR",
|
||||
"ALL",
|
||||
"FILE",
|
||||
"EXTENSION",
|
||||
"CRAWLED_URL",
|
||||
"YOUTUBE_VIDEO",
|
||||
"SLACK_CONNECTOR",
|
||||
"NOTION_CONNECTOR",
|
||||
"GITHUB_CONNECTOR",
|
||||
"LINEAR_CONNECTOR",
|
||||
"DISCORD_CONNECTOR",
|
||||
"JIRA_CONNECTOR",
|
||||
];
|
||||
|
||||
const getDocumentTypeColor = (type: DocumentType) => {
|
||||
const colors = {
|
||||
FILE: "bg-blue-50 text-blue-700 border-blue-200",
|
||||
EXTENSION: "bg-green-50 text-green-700 border-green-200",
|
||||
CRAWLED_URL: "bg-purple-50 text-purple-700 border-purple-200",
|
||||
YOUTUBE_VIDEO: "bg-red-50 text-red-700 border-red-200",
|
||||
SLACK_CONNECTOR: "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||
NOTION_CONNECTOR: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||
GITHUB_CONNECTOR: "bg-gray-50 text-gray-700 border-gray-200",
|
||||
LINEAR_CONNECTOR: "bg-pink-50 text-pink-700 border-pink-200",
|
||||
DISCORD_CONNECTOR: "bg-violet-50 text-violet-700 border-violet-200",
|
||||
JIRA_CONNECTOR: "bg-orange-50 text-orange-700 border-orange-200",
|
||||
};
|
||||
return colors[type] || "bg-gray-50 text-gray-700 border-gray-200";
|
||||
const colors = {
|
||||
FILE: "bg-blue-50 text-blue-700 border-blue-200",
|
||||
EXTENSION: "bg-green-50 text-green-700 border-green-200",
|
||||
CRAWLED_URL: "bg-purple-50 text-purple-700 border-purple-200",
|
||||
YOUTUBE_VIDEO: "bg-red-50 text-red-700 border-red-200",
|
||||
SLACK_CONNECTOR: "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||
NOTION_CONNECTOR: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||
GITHUB_CONNECTOR: "bg-gray-50 text-gray-700 border-gray-200",
|
||||
LINEAR_CONNECTOR: "bg-pink-50 text-pink-700 border-pink-200",
|
||||
DISCORD_CONNECTOR: "bg-violet-50 text-violet-700 border-violet-200",
|
||||
JIRA_CONNECTOR: "bg-orange-50 text-orange-700 border-orange-200",
|
||||
};
|
||||
return colors[type] || "bg-gray-50 text-gray-700 border-gray-200";
|
||||
};
|
||||
|
||||
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 (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${getDocumentTypeColor(
|
||||
type,
|
||||
)} text-[10px] sm:text-xs px-1 sm:px-2`}
|
||||
>
|
||||
<span className="hidden sm:inline">{type.replace(/_/g, " ")}</span>
|
||||
<span className="sm:hidden">{type.split("_")[0]}</span>
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
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,
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${getDocumentTypeColor(type)} text-[10px] sm:text-xs px-1 sm:px-2`}
|
||||
>
|
||||
<span className="hidden sm:inline">{type.replace(/_/g, " ")}</span>
|
||||
<span className="sm:hidden">{type.split("_")[0]}</span>
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
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({
|
||||
documents,
|
||||
onSelectionChange,
|
||||
onDone,
|
||||
initialSelectedDocuments = [],
|
||||
documents,
|
||||
onSelectionChange,
|
||||
onDone,
|
||||
initialSelectedDocuments = [],
|
||||
}: DocumentsDataTableProps) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[],
|
||||
);
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [documentTypeFilter, setDocumentTypeFilter] = React.useState<
|
||||
DocumentType | "ALL"
|
||||
>("ALL");
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
||||
const [documentTypeFilter, setDocumentTypeFilter] = React.useState<DocumentType | "ALL">("ALL");
|
||||
|
||||
// Memoize initial row selection to prevent infinite loops
|
||||
const initialRowSelection = React.useMemo(() => {
|
||||
if (!documents.length || !initialSelectedDocuments.length) return {};
|
||||
// Memoize initial row selection to prevent infinite loops
|
||||
const initialRowSelection = React.useMemo(() => {
|
||||
if (!documents.length || !initialSelectedDocuments.length) return {};
|
||||
|
||||
const selection: Record<string, boolean> = {};
|
||||
initialSelectedDocuments.forEach((selectedDoc) => {
|
||||
selection[selectedDoc.id] = true;
|
||||
});
|
||||
return selection;
|
||||
}, [documents, initialSelectedDocuments]);
|
||||
const selection: Record<string, boolean> = {};
|
||||
initialSelectedDocuments.forEach((selectedDoc) => {
|
||||
selection[selectedDoc.id] = true;
|
||||
});
|
||||
return selection;
|
||||
}, [documents, initialSelectedDocuments]);
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({});
|
||||
|
||||
// Only update row selection when initialRowSelection actually changes and is not empty
|
||||
React.useEffect(() => {
|
||||
const hasChanges =
|
||||
JSON.stringify(rowSelection) !== JSON.stringify(initialRowSelection);
|
||||
if (hasChanges && Object.keys(initialRowSelection).length > 0) {
|
||||
setRowSelection(initialRowSelection);
|
||||
}
|
||||
}, [initialRowSelection]);
|
||||
// Only update row selection when initialRowSelection actually changes and is not empty
|
||||
React.useEffect(() => {
|
||||
const hasChanges = JSON.stringify(rowSelection) !== JSON.stringify(initialRowSelection);
|
||||
if (hasChanges && Object.keys(initialRowSelection).length > 0) {
|
||||
setRowSelection(initialRowSelection);
|
||||
}
|
||||
}, [initialRowSelection]);
|
||||
|
||||
// Initialize row selection on mount
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
Object.keys(rowSelection).length === 0 &&
|
||||
Object.keys(initialRowSelection).length > 0
|
||||
) {
|
||||
setRowSelection(initialRowSelection);
|
||||
}
|
||||
}, []);
|
||||
// Initialize row selection on mount
|
||||
React.useEffect(() => {
|
||||
if (Object.keys(rowSelection).length === 0 && Object.keys(initialRowSelection).length > 0) {
|
||||
setRowSelection(initialRowSelection);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filteredDocuments = React.useMemo(() => {
|
||||
if (documentTypeFilter === "ALL") return documents;
|
||||
return documents.filter((doc) => doc.document_type === documentTypeFilter);
|
||||
}, [documents, documentTypeFilter]);
|
||||
const filteredDocuments = React.useMemo(() => {
|
||||
if (documentTypeFilter === "ALL") return documents;
|
||||
return documents.filter((doc) => doc.document_type === documentTypeFilter);
|
||||
}, [documents, documentTypeFilter]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredDocuments,
|
||||
columns,
|
||||
getRowId: (row) => row.id.toString(),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
initialState: { pagination: { pageSize: 10 } },
|
||||
state: { sorting, columnFilters, columnVisibility, rowSelection },
|
||||
});
|
||||
const table = useReactTable({
|
||||
data: filteredDocuments,
|
||||
columns,
|
||||
getRowId: (row) => row.id.toString(),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
initialState: { pagination: { pageSize: 10 } },
|
||||
state: { sorting, columnFilters, columnVisibility, rowSelection },
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||
const selectedDocuments = selectedRows.map((row) => row.original);
|
||||
onSelectionChange(selectedDocuments);
|
||||
}, [rowSelection, onSelectionChange, table]);
|
||||
React.useEffect(() => {
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||
const selectedDocuments = selectedRows.map((row) => row.original);
|
||||
onSelectionChange(selectedDocuments);
|
||||
}, [rowSelection, onSelectionChange, table]);
|
||||
|
||||
const handleClearAll = () => setRowSelection({});
|
||||
const handleClearAll = () => setRowSelection({});
|
||||
|
||||
const handleSelectPage = () => {
|
||||
const currentPageRows = table.getRowModel().rows;
|
||||
const newSelection = { ...rowSelection };
|
||||
currentPageRows.forEach((row) => {
|
||||
newSelection[row.id] = true;
|
||||
});
|
||||
setRowSelection(newSelection);
|
||||
};
|
||||
const handleSelectPage = () => {
|
||||
const currentPageRows = table.getRowModel().rows;
|
||||
const newSelection = { ...rowSelection };
|
||||
currentPageRows.forEach((row) => {
|
||||
newSelection[row.id] = true;
|
||||
});
|
||||
setRowSelection(newSelection);
|
||||
};
|
||||
|
||||
const handleSelectAllFiltered = () => {
|
||||
const allFilteredRows = table.getFilteredRowModel().rows;
|
||||
const newSelection: Record<string, boolean> = {};
|
||||
allFilteredRows.forEach((row) => {
|
||||
newSelection[row.id] = true;
|
||||
});
|
||||
setRowSelection(newSelection);
|
||||
};
|
||||
const handleSelectAllFiltered = () => {
|
||||
const allFilteredRows = table.getFilteredRowModel().rows;
|
||||
const newSelection: Record<string, boolean> = {};
|
||||
allFilteredRows.forEach((row) => {
|
||||
newSelection[row.id] = true;
|
||||
});
|
||||
setRowSelection(newSelection);
|
||||
};
|
||||
|
||||
const selectedCount = table.getFilteredSelectedRowModel().rows.length;
|
||||
const totalFiltered = table.getFilteredRowModel().rows.length;
|
||||
const selectedCount = table.getFilteredSelectedRowModel().rows.length;
|
||||
const totalFiltered = table.getFilteredRowModel().rows.length;
|
||||
|
||||
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={
|
||||
(table.getColumn("title")?.getFilterValue() as string) ?? ""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table.getColumn("title")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={documentTypeFilter}
|
||||
onValueChange={(value) =>
|
||||
setDocumentTypeFilter(value as DocumentType | "ALL")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOCUMENT_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type === "ALL" ? "All Types" : type.replace(/_/g, " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
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={(table.getColumn("title")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => table.getColumn("title")?.setFilterValue(event.target.value)}
|
||||
className="pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={documentTypeFilter}
|
||||
onValueChange={(value) => setDocumentTypeFilter(value as DocumentType | "ALL")}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOCUMENT_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type === "ALL" ? "All Types" : type.replace(/_/g, " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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} of {totalFiltered} selected
|
||||
</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"
|
||||
>
|
||||
Select Page
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSelectAllFiltered}
|
||||
className="text-xs sm:text-sm hidden sm:inline-flex"
|
||||
>
|
||||
Select All Filtered
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSelectAllFiltered}
|
||||
className="text-xs sm:hidden"
|
||||
>
|
||||
Select All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onDone}
|
||||
disabled={selectedCount === 0}
|
||||
className="w-full sm:w-auto sm:min-w-[100px]"
|
||||
>
|
||||
Done ({selectedCount})
|
||||
</Button>
|
||||
</div>
|
||||
</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} of {totalFiltered} selected
|
||||
</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"
|
||||
>
|
||||
Select Page
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSelectAllFiltered}
|
||||
className="text-xs sm:text-sm hidden sm:inline-flex"
|
||||
>
|
||||
Select All Filtered
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSelectAllFiltered}
|
||||
className="text-xs sm:hidden"
|
||||
>
|
||||
Select All
|
||||
</Button>
|
||||
</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">
|
||||
<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-32 text-center text-muted-foreground text-sm"
|
||||
>
|
||||
No documents found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
{/* Table Container */}
|
||||
<div className="border rounded-lg flex-1 min-h-0 overflow-hidden bg-background">
|
||||
<div className="overflow-auto h-full">
|
||||
<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-32 text-center text-muted-foreground text-sm"
|
||||
>
|
||||
No documents found.
|
||||
</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{" "}
|
||||
{table.getState().pagination.pageIndex *
|
||||
table.getState().pagination.pageSize +
|
||||
1}{" "}
|
||||
to{" "}
|
||||
{Math.min(
|
||||
(table.getState().pagination.pageIndex + 1) *
|
||||
table.getState().pagination.pageSize,
|
||||
table.getFilteredRowModel().rows.length,
|
||||
)}{" "}
|
||||
of {table.getFilteredRowModel().rows.length} documents
|
||||
</div>
|
||||
<div className="flex items-center justify-center sm:justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
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>{table.getState().pagination.pageIndex + 1}</strong>
|
||||
<span>of</span>
|
||||
<strong>{table.getPageCount()}</strong>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</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 {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}{" "}
|
||||
to{" "}
|
||||
{Math.min(
|
||||
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
|
||||
table.getFilteredRowModel().rows.length
|
||||
)}{" "}
|
||||
of {table.getFilteredRowModel().rows.length} documents
|
||||
</div>
|
||||
<div className="flex items-center justify-center sm:justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
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>{table.getState().pagination.pageIndex + 1}</strong>
|
||||
<span>of</span>
|
||||
<strong>{table.getPageCount()}</strong>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,80 +1,81 @@
|
|||
import { RefObject, useEffect } from 'react';
|
||||
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' });
|
||||
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);
|
||||
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
|
||||
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
|
||||
}
|
||||
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
|
||||
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);
|
||||
}, []);
|
||||
|
||||
return updateIndicators;
|
||||
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);
|
||||
}, []);
|
||||
|
||||
return updateIndicators;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to scroll tabs list left
|
||||
*/
|
||||
export const scrollTabsLeft = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
updateIndicators: () => void
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
updateIndicators: () => void
|
||||
) => {
|
||||
if (tabsListRef.current) {
|
||||
tabsListRef.current.scrollBy({ left: -200, behavior: 'smooth' });
|
||||
// Update indicators after scrolling
|
||||
setTimeout(updateIndicators, 300);
|
||||
}
|
||||
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
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
updateIndicators: () => void
|
||||
) => {
|
||||
if (tabsListRef.current) {
|
||||
tabsListRef.current.scrollBy({ left: 200, behavior: 'smooth' });
|
||||
// Update indicators after scrolling
|
||||
setTimeout(updateIndicators, 300);
|
||||
}
|
||||
};
|
||||
if (tabsListRef.current) {
|
||||
tabsListRef.current.scrollBy({ left: 200, behavior: "smooth" });
|
||||
// Update indicators after scrolling
|
||||
setTimeout(updateIndicators, 300);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,38 +1,40 @@
|
|||
import React from 'react';
|
||||
import type React from "react";
|
||||
|
||||
type SegmentedControlProps<T extends string> = {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: Array<{
|
||||
value: T;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}>;
|
||||
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>
|
||||
);
|
||||
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;
|
||||
export default SegmentedControl;
|
||||
|
|
|
|||
|
|
@ -1,68 +1,69 @@
|
|||
import { Source, Connector } from './types';
|
||||
import type { Source, Connector } from "./types";
|
||||
|
||||
/**
|
||||
* Function to get sources for the main view
|
||||
*/
|
||||
export const getMainViewSources = (connector: Connector, initialSourcesDisplay: number) => {
|
||||
return connector.sources?.slice(0, initialSourcesDisplay);
|
||||
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)
|
||||
);
|
||||
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
|
||||
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);
|
||||
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;
|
||||
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[]
|
||||
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;
|
||||
};
|
||||
for (const connector of connectorSources) {
|
||||
const source = connector.sources?.find((s) => s.id === citationId);
|
||||
if (source) {
|
||||
return {
|
||||
...source,
|
||||
connectorType: connector.type,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
// Export all components and utilities from the chat folder
|
||||
export { default as SegmentedControl } from './SegmentedControl';
|
||||
export * from './ConnectorComponents';
|
||||
export * from './Citation';
|
||||
export * from './SourceUtils';
|
||||
export * from './ScrollUtils';
|
||||
export * from './CodeBlock';
|
||||
export * from './types';
|
||||
export { default as SegmentedControl } from "./SegmentedControl";
|
||||
export * from "./ConnectorComponents";
|
||||
export * from "./Citation";
|
||||
export * from "./SourceUtils";
|
||||
export * from "./ScrollUtils";
|
||||
export * from "./CodeBlock";
|
||||
export * from "./types";
|
||||
|
|
|
|||
|
|
@ -3,49 +3,48 @@
|
|||
*/
|
||||
|
||||
export type Source = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
connectorType?: string;
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
connectorType?: string;
|
||||
};
|
||||
|
||||
export type Connector = {
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
sources?: Source[];
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
sources?: Source[];
|
||||
};
|
||||
|
||||
export type StatusMessage = {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'info' | 'success' | 'error' | 'warning';
|
||||
timestamp: string;
|
||||
id: number;
|
||||
message: string;
|
||||
type: "info" | "success" | "error" | "warning";
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
// Define message types to match useChat() structure
|
||||
export type MessageRole = 'user' | 'assistant' | 'system' | 'data';
|
||||
export type MessageRole = "user" | "assistant" | "system" | "data";
|
||||
|
||||
export interface ToolInvocation {
|
||||
state: 'call' | 'result';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: any;
|
||||
result?: any;
|
||||
state: "call" | "result";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: any;
|
||||
result?: any;
|
||||
}
|
||||
|
||||
export interface ToolInvocationUIPart {
|
||||
type: 'tool-invocation';
|
||||
toolInvocation: ToolInvocation;
|
||||
type: "tool-invocation";
|
||||
toolInvocation: ToolInvocation;
|
||||
}
|
||||
|
||||
|
||||
export type ResearchMode = 'QNA' | 'REPORT_GENERAL' | 'REPORT_DEEP' | 'REPORT_DEEPER';
|
||||
export type ResearchMode = "QNA" | "REPORT_GENERAL" | "REPORT_DEEP" | "REPORT_DEEPER";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue