mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-04 22:02:16 +02:00
feat: monorepo
This commit is contained in:
parent
fe39077849
commit
a1474ca49e
144 changed files with 43821 additions and 1 deletions
112
surfsense_web/components/chat/Citation.tsx
Normal file
112
surfsense_web/components/chat/Citation.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
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';
|
||||
|
||||
type CitationProps = {
|
||||
citationId: number;
|
||||
citationText: string;
|
||||
position: number;
|
||||
source: Source | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Citation component to handle individual citations
|
||||
*/
|
||||
export const Citation = ({ 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>
|
||||
<DropdownMenuContent align="start" className="w-80 p-0">
|
||||
<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')}
|
||||
title="Open in new tab"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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;
|
||||
};
|
||||
162
surfsense_web/components/chat/ConnectorComponents.tsx
Normal file
162
surfsense_web/components/chat/ConnectorComponents.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Search,
|
||||
Globe,
|
||||
BookOpen,
|
||||
Sparkles,
|
||||
Microscope,
|
||||
Telescope,
|
||||
File,
|
||||
Link,
|
||||
Slack,
|
||||
Webhook
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Connector, ResearchMode } from './types';
|
||||
|
||||
// Helper function to get connector icon
|
||||
export const getConnectorIcon = (connectorType: string) => {
|
||||
const iconProps = { className: "h-4 w-4" };
|
||||
|
||||
switch(connectorType) {
|
||||
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 <Slack {...iconProps} />;
|
||||
case 'NOTION_CONNECTOR':
|
||||
return <BookOpen {...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: 'GENERAL',
|
||||
label: 'General',
|
||||
icon: getConnectorIcon('GENERAL')
|
||||
},
|
||||
{
|
||||
value: 'DEEP',
|
||||
label: 'Deep',
|
||||
icon: getConnectorIcon('DEEP')
|
||||
},
|
||||
{
|
||||
value: '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>
|
||||
);
|
||||
|
||||
/**
|
||||
* Displays a count indicator for additional connectors
|
||||
*/
|
||||
export const ConnectorCountBadge = ({ count }: { count: number }) => (
|
||||
<div className="w-4 h-4 rounded-full flex items-center justify-center bg-primary text-primary-foreground text-[8px] font-medium border border-background z-0">
|
||||
+{count}
|
||||
</div>
|
||||
);
|
||||
|
||||
type ConnectorButtonProps = {
|
||||
selectedConnectors: string[];
|
||||
onClick: () => void;
|
||||
connectorSources: Connector[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Button that displays selected connectors and opens connector selection dialog
|
||||
*/
|
||||
export const ConnectorButton = ({ selectedConnectors, onClick, connectorSources }: ConnectorButtonProps) => {
|
||||
const totalConnectors = connectorSources.length;
|
||||
const selectedCount = selectedConnectors.length;
|
||||
const progressPercentage = (selectedCount / totalConnectors) * 100;
|
||||
|
||||
// Get the name of a single selected connector
|
||||
const getSingleConnectorName = () => {
|
||||
const connector = connectorSources.find(c => c.type === selectedConnectors[0]);
|
||||
return connector?.name || '';
|
||||
};
|
||||
|
||||
// Get display text based on selection count
|
||||
const getDisplayText = () => {
|
||||
if (selectedCount === totalConnectors) return "All Connectors";
|
||||
if (selectedCount === 1) return getSingleConnectorName();
|
||||
return `${selectedCount} Connectors`;
|
||||
};
|
||||
|
||||
// Render the empty state (no connectors selected)
|
||||
const renderEmptyState = () => (
|
||||
<>
|
||||
<Plus className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Select Connectors</span>
|
||||
</>
|
||||
);
|
||||
|
||||
// Render the selected connectors preview
|
||||
const renderSelectedConnectors = () => (
|
||||
<>
|
||||
<div className="flex -space-x-1.5 mr-1">
|
||||
{/* Show up to 3 connector icons */}
|
||||
{selectedConnectors.slice(0, 3).map((type, index) => (
|
||||
<ConnectorIcon key={type} type={type} index={index} />
|
||||
))}
|
||||
|
||||
{/* Show count indicator if more than 3 connectors are selected */}
|
||||
{selectedCount > 3 && <ConnectorCountBadge count={selectedCount - 3} />}
|
||||
</div>
|
||||
|
||||
{/* Display text */}
|
||||
<span className="font-medium">{getDisplayText()}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs font-medium rounded-md border-border relative overflow-hidden group scale-90 origin-left"
|
||||
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>
|
||||
);
|
||||
};
|
||||
80
surfsense_web/components/chat/ScrollUtils.tsx
Normal file
80
surfsense_web/components/chat/ScrollUtils.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import React, { RefObject, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Function to scroll to the bottom of a container
|
||||
*/
|
||||
export const scrollToBottom = (ref: RefObject<HTMLDivElement>) => {
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to scroll to bottom when messages change
|
||||
*/
|
||||
export const useScrollToBottom = (ref: RefObject<HTMLDivElement>, dependencies: any[]) => {
|
||||
useEffect(() => {
|
||||
scrollToBottom(ref);
|
||||
}, dependencies);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to check scroll position and update indicators
|
||||
*/
|
||||
export const updateScrollIndicators = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
setCanScrollLeft: (value: boolean) => void,
|
||||
setCanScrollRight: (value: boolean) => void
|
||||
) => {
|
||||
if (tabsListRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = tabsListRef.current;
|
||||
setCanScrollLeft(scrollLeft > 0);
|
||||
setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 10); // 10px buffer
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to initialize scroll indicators and add resize listener
|
||||
*/
|
||||
export const useScrollIndicators = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
setCanScrollLeft: (value: boolean) => void,
|
||||
setCanScrollRight: (value: boolean) => void
|
||||
) => {
|
||||
const updateIndicators = () => updateScrollIndicators(tabsListRef, setCanScrollLeft, setCanScrollRight);
|
||||
|
||||
useEffect(() => {
|
||||
updateIndicators();
|
||||
// Add resize listener to update indicators when window size changes
|
||||
window.addEventListener('resize', updateIndicators);
|
||||
return () => window.removeEventListener('resize', updateIndicators);
|
||||
}, []);
|
||||
|
||||
return updateIndicators;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to scroll tabs list left
|
||||
*/
|
||||
export const scrollTabsLeft = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
updateIndicators: () => void
|
||||
) => {
|
||||
if (tabsListRef.current) {
|
||||
tabsListRef.current.scrollBy({ left: -200, behavior: 'smooth' });
|
||||
// Update indicators after scrolling
|
||||
setTimeout(updateIndicators, 300);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to scroll tabs list right
|
||||
*/
|
||||
export const scrollTabsRight = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
updateIndicators: () => void
|
||||
) => {
|
||||
if (tabsListRef.current) {
|
||||
tabsListRef.current.scrollBy({ left: 200, behavior: 'smooth' });
|
||||
// Update indicators after scrolling
|
||||
setTimeout(updateIndicators, 300);
|
||||
}
|
||||
};
|
||||
38
surfsense_web/components/chat/SegmentedControl.tsx
Normal file
38
surfsense_web/components/chat/SegmentedControl.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
|
||||
type SegmentedControlProps<T extends string> = {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: Array<{
|
||||
value: T;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A segmented control component for selecting between different options
|
||||
*/
|
||||
function SegmentedControl<T extends string>({ value, onChange, options }: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div className="flex rounded-md border border-border overflow-hidden scale-90 origin-left">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={`flex items-center gap-1 px-2 py-1 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;
|
||||
69
surfsense_web/components/chat/SourceUtils.tsx
Normal file
69
surfsense_web/components/chat/SourceUtils.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import React from 'react';
|
||||
import { 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);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get filtered sources for the dialog
|
||||
*/
|
||||
export const getFilteredSources = (connector: Connector, sourceFilter: string) => {
|
||||
if (!sourceFilter.trim()) {
|
||||
return connector.sources;
|
||||
}
|
||||
|
||||
const filter = sourceFilter.toLowerCase().trim();
|
||||
return connector.sources?.filter(source =>
|
||||
source.title.toLowerCase().includes(filter) ||
|
||||
source.description.toLowerCase().includes(filter)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get paginated and filtered sources for the dialog
|
||||
*/
|
||||
export const getPaginatedDialogSources = (
|
||||
connector: Connector,
|
||||
sourceFilter: string,
|
||||
expandedSources: boolean,
|
||||
sourcesPage: number,
|
||||
sourcesPerPage: number
|
||||
) => {
|
||||
const filteredSources = getFilteredSources(connector, sourceFilter);
|
||||
|
||||
if (expandedSources) {
|
||||
return filteredSources;
|
||||
}
|
||||
return filteredSources?.slice(0, sourcesPage * sourcesPerPage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get the count of sources for a connector type
|
||||
*/
|
||||
export const getSourcesCount = (connectorSources: Connector[], connectorType: string) => {
|
||||
const connector = connectorSources.find(c => c.type === connectorType);
|
||||
return connector?.sources?.length || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get a citation source by ID
|
||||
*/
|
||||
export const getCitationSource = (
|
||||
citationId: number,
|
||||
connectorSources: Connector[]
|
||||
): Source | null => {
|
||||
for (const connector of connectorSources) {
|
||||
const source = connector.sources?.find(s => s.id === citationId);
|
||||
if (source) {
|
||||
return {
|
||||
...source,
|
||||
connectorType: connector.type
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
18
surfsense_web/components/chat/connector-sources.ts
Normal file
18
surfsense_web/components/chat/connector-sources.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Connector sources
|
||||
export const connectorSourcesMenu = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Crawled URL",
|
||||
type: "CRAWLED_URL",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "File",
|
||||
type: "FILE",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Extension",
|
||||
type: "EXTENSION",
|
||||
},
|
||||
];
|
||||
7
surfsense_web/components/chat/index.ts
Normal file
7
surfsense_web/components/chat/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// 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 './types';
|
||||
51
surfsense_web/components/chat/types.ts
Normal file
51
surfsense_web/components/chat/types.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Types for chat components
|
||||
*/
|
||||
|
||||
export type Source = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
connectorType?: string;
|
||||
};
|
||||
|
||||
export type Connector = {
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
sources?: Source[];
|
||||
};
|
||||
|
||||
export type StatusMessage = {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'info' | 'success' | 'error' | 'warning';
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
// Define message types to match useChat() structure
|
||||
export type MessageRole = 'user' | 'assistant' | 'system' | 'data';
|
||||
|
||||
export interface ToolInvocation {
|
||||
state: 'call' | 'result';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: any;
|
||||
result?: any;
|
||||
}
|
||||
|
||||
export interface ToolInvocationUIPart {
|
||||
type: 'tool-invocation';
|
||||
toolInvocation: ToolInvocation;
|
||||
}
|
||||
|
||||
|
||||
export type ResearchMode = 'GENERAL' | 'DEEP' | 'DEEPER' | 'DEEPEST';
|
||||
Loading…
Add table
Add a link
Reference in a new issue