mirror of
https://github.com/ItzCrazyKns/Perplexica.git
synced 2025-12-14 07:38:14 +00:00
268 lines
12 KiB
TypeScript
268 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
Brain,
|
|
Search,
|
|
FileText,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
BookSearch,
|
|
} from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { useEffect, useState } from 'react';
|
|
import { ResearchBlock, ResearchBlockSubStep } from '@/lib/types';
|
|
import { useChat } from '@/lib/hooks/useChat';
|
|
|
|
const getStepIcon = (step: ResearchBlockSubStep) => {
|
|
if (step.type === 'reasoning') {
|
|
return <Brain className="w-4 h-4" />;
|
|
} else if (step.type === 'searching' || step.type === 'upload_searching') {
|
|
return <Search className="w-4 h-4" />;
|
|
} else if (
|
|
step.type === 'search_results' ||
|
|
step.type === 'upload_search_results'
|
|
) {
|
|
return <FileText className="w-4 h-4" />;
|
|
} else if (step.type === 'reading') {
|
|
return <BookSearch className="w-4 h-4" />;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const getStepTitle = (
|
|
step: ResearchBlockSubStep,
|
|
isStreaming: boolean,
|
|
): string => {
|
|
if (step.type === 'reasoning') {
|
|
return isStreaming && !step.reasoning ? 'Thinking...' : 'Thinking';
|
|
} else if (step.type === 'searching') {
|
|
return `Searching ${step.searching.length} ${step.searching.length === 1 ? 'query' : 'queries'}`;
|
|
} else if (step.type === 'search_results') {
|
|
return `Found ${step.reading.length} ${step.reading.length === 1 ? 'result' : 'results'}`;
|
|
} else if (step.type === 'reading') {
|
|
return `Reading ${step.reading.length} ${step.reading.length === 1 ? 'source' : 'sources'}`;
|
|
} else if (step.type === 'upload_searching') {
|
|
return 'Scanning your uploaded documents';
|
|
} else if (step.type === 'upload_search_results') {
|
|
return `Reading ${step.results.length} ${step.results.length === 1 ? 'document' : 'documents'}`;
|
|
}
|
|
|
|
return 'Processing';
|
|
};
|
|
|
|
const AssistantSteps = ({
|
|
block,
|
|
status,
|
|
}: {
|
|
block: ResearchBlock;
|
|
status: 'answering' | 'completed' | 'error';
|
|
}) => {
|
|
const [isExpanded, setIsExpanded] = useState(true);
|
|
const { researchEnded, loading } = useChat();
|
|
|
|
useEffect(() => {
|
|
if (researchEnded) {
|
|
setIsExpanded(false);
|
|
} else if (status === 'answering') {
|
|
setIsExpanded(true);
|
|
}
|
|
}, [researchEnded, status]);
|
|
|
|
if (!block || block.data.subSteps.length === 0) return null;
|
|
|
|
return (
|
|
<div className="rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 overflow-hidden">
|
|
<button
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="w-full flex items-center justify-between p-3 hover:bg-light-200 dark:hover:bg-dark-200 transition duration-200"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Brain className="w-4 h-4 text-black dark:text-white" />
|
|
<span className="text-sm font-medium text-black dark:text-white">
|
|
Research Progress ({block.data.subSteps.length}{' '}
|
|
{block.data.subSteps.length === 1 ? 'step' : 'steps'})
|
|
</span>
|
|
</div>
|
|
{isExpanded ? (
|
|
<ChevronUp className="w-4 h-4 text-black/70 dark:text-white/70" />
|
|
) : (
|
|
<ChevronDown className="w-4 h-4 text-black/70 dark:text-white/70" />
|
|
)}
|
|
</button>
|
|
|
|
<AnimatePresence>
|
|
{isExpanded && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="border-t border-light-200 dark:border-dark-200"
|
|
>
|
|
<div className="p-3 space-y-2">
|
|
{block.data.subSteps.map((step, index) => {
|
|
const isLastStep = index === block.data.subSteps.length - 1;
|
|
const isStreaming = loading && isLastStep && !researchEnded;
|
|
|
|
return (
|
|
<motion.div
|
|
key={step.id}
|
|
initial={{ opacity: 0, x: -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ duration: 0.2, delay: 0 }}
|
|
className="flex gap-2"
|
|
>
|
|
<div className="flex flex-col items-center -mt-0.5">
|
|
<div
|
|
className={`rounded-full p-1.5 bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 ${isStreaming ? 'animate-pulse' : ''}`}
|
|
>
|
|
{getStepIcon(step)}
|
|
</div>
|
|
{index < block.data.subSteps.length - 1 && (
|
|
<div className="w-0.5 flex-1 min-h-[20px] bg-light-200 dark:bg-dark-200 mt-1.5" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 pb-1">
|
|
<span className="text-sm font-medium text-black dark:text-white">
|
|
{getStepTitle(step, isStreaming)}
|
|
</span>
|
|
|
|
{step.type === 'reasoning' && (
|
|
<>
|
|
{step.reasoning && (
|
|
<p className="text-xs text-black/70 dark:text-white/70 mt-0.5">
|
|
{step.reasoning}
|
|
</p>
|
|
)}
|
|
{isStreaming && !step.reasoning && (
|
|
<div className="flex items-center gap-1.5 mt-0.5">
|
|
<div
|
|
className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-bounce"
|
|
style={{ animationDelay: '0ms' }}
|
|
/>
|
|
<div
|
|
className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-bounce"
|
|
style={{ animationDelay: '150ms' }}
|
|
/>
|
|
<div
|
|
className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-bounce"
|
|
style={{ animationDelay: '300ms' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{step.type === 'searching' &&
|
|
step.searching.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
|
{step.searching.map((query, idx) => (
|
|
<span
|
|
key={idx}
|
|
className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
|
|
>
|
|
{query}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{(step.type === 'search_results' ||
|
|
step.type === 'reading') &&
|
|
step.reading.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
|
{step.reading.slice(0, 4).map((result, idx) => {
|
|
const url = result.metadata.url || '';
|
|
const title = result.metadata.title || 'Untitled';
|
|
const domain = url ? new URL(url).hostname : '';
|
|
const faviconUrl = domain
|
|
? `https://s2.googleusercontent.com/s2/favicons?domain=${domain}&sz=128`
|
|
: '';
|
|
|
|
return (
|
|
<span
|
|
key={idx}
|
|
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
|
|
>
|
|
{faviconUrl && (
|
|
<img
|
|
src={faviconUrl}
|
|
alt=""
|
|
className="w-3 h-3 rounded-sm flex-shrink-0"
|
|
onError={(e) => {
|
|
e.currentTarget.style.display = 'none';
|
|
}}
|
|
/>
|
|
)}
|
|
<span className="line-clamp-1">{title}</span>
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{step.type === 'upload_searching' &&
|
|
step.queries.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
|
{step.queries.map((query, idx) => (
|
|
<span
|
|
key={idx}
|
|
className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
|
|
>
|
|
{query}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{step.type === 'upload_search_results' &&
|
|
step.results.length > 0 && (
|
|
<div className="mt-1.5 space-y-2">
|
|
{step.results.slice(0, 4).map((result, idx) => {
|
|
const title =
|
|
(result.metadata &&
|
|
(result.metadata.title ||
|
|
result.metadata.fileName)) ||
|
|
'Untitled document';
|
|
const snippet = (result.content || '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, 220);
|
|
|
|
return (
|
|
<div
|
|
key={idx}
|
|
className="flex gap-3 items-start rounded-lg border border-light-200 dark:border-dark-200 bg-light-100 dark:bg-dark-100 p-2"
|
|
>
|
|
<div className="mt-0.5 h-10 w-10 rounded-md bg-cyan-100 text-cyan-800 dark:bg-sky-500 dark:text-cyan-50 flex items-center justify-center">
|
|
<FileText className="w-5 h-5" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-semibold text-black dark:text-white line-clamp-1">
|
|
{title}
|
|
</div>
|
|
<div className="text-xs text-black/70 dark:text-white/70 mt-0.5 leading-relaxed line-clamp-3">
|
|
{snippet || 'No preview available.'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AssistantSteps;
|