- localization-svc: defaultLocale ru, resolveLocale only by geo - web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru - countryToLocale: default ru when no country or unknown country Co-authored-by: Cursor <cursoragent@cursor.com>
283 lines
12 KiB
TypeScript
283 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';
|
||
import { useTranslation } from '@/lib/localization/context';
|
||
|
||
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,
|
||
t: (key: string) => string,
|
||
): string => {
|
||
if (step.type === 'reasoning') {
|
||
return isStreaming && !step.reasoning ? t('chat.brainstorming') : t('chat.thinking');
|
||
} else if (step.type === 'searching') {
|
||
const n = step.searching.length;
|
||
return t('chat.searchingQueries').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.query') : t('chat.queries'));
|
||
} else if (step.type === 'search_results') {
|
||
const n = step.reading.length;
|
||
return t('chat.foundResults').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.result') : t('chat.results'));
|
||
} else if (step.type === 'reading') {
|
||
const n = step.reading.length;
|
||
return t('chat.readingSources').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.source') : t('chat.sources'));
|
||
} else if (step.type === 'upload_searching') {
|
||
return t('chat.scanningDocs');
|
||
} else if (step.type === 'upload_search_results') {
|
||
const n = step.results.length;
|
||
return t('chat.readingDocs').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.document') : t('chat.documents'));
|
||
}
|
||
|
||
return t('chat.processing');
|
||
};
|
||
|
||
const AssistantSteps = ({
|
||
block,
|
||
status,
|
||
isLast,
|
||
}: {
|
||
block: ResearchBlock;
|
||
status: 'answering' | 'completed' | 'error';
|
||
isLast: boolean;
|
||
}) => {
|
||
const { t } = useTranslation();
|
||
const [isExpanded, setIsExpanded] = useState(
|
||
isLast && status === 'answering' ? true : false,
|
||
);
|
||
const { researchEnded, loading } = useChat();
|
||
|
||
useEffect(() => {
|
||
if (researchEnded && isLast) {
|
||
setIsExpanded(false);
|
||
} else if (status === 'answering' && isLast) {
|
||
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">
|
||
{t('chat.researchProgress')} ({block.data.subSteps.length}{' '}
|
||
{block.data.subSteps.length === 1 ? t('chat.step') : t('chat.steps')})
|
||
{status === 'answering' && (
|
||
<span className="ml-2 font-normal text-black/60 dark:text-white/60">
|
||
(~30–90 sec)
|
||
</span>
|
||
)}
|
||
</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, t)}
|
||
</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 = typeof result.metadata?.url === 'string' ? result.metadata.url : '';
|
||
const title = String(result.metadata?.title ?? 'Untitled');
|
||
let domain = '';
|
||
try {
|
||
if (url) domain = new URL(url).hostname;
|
||
} catch {
|
||
/* invalid url */
|
||
}
|
||
const faviconUrl = domain
|
||
? `https://s2.googleusercontent.com/s2/favicons?domain=${domain}&sz=128`
|
||
: '';
|
||
|
||
return (
|
||
<a
|
||
key={idx}
|
||
href={url}
|
||
target="_blank"
|
||
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>
|
||
</a>
|
||
);
|
||
})}
|
||
</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 grid gap-3 lg:grid-cols-3">
|
||
{step.results.slice(0, 4).map((result, idx) => {
|
||
const raw =
|
||
result.metadata?.title ?? result.metadata?.fileName;
|
||
const title =
|
||
typeof raw === 'string' ? raw : 'Untitled document';
|
||
|
||
return (
|
||
<div
|
||
key={idx}
|
||
className="flex flex-row space-x-3 rounded-lg border border-light-200 dark:border-dark-200 bg-light-100 dark:bg-dark-100 p-2 cursor-pointer"
|
||
>
|
||
<div className="mt-0.5 h-10 w-10 rounded-md bg-[#EA580C]/20 text-[#EA580C] dark:bg-[#EA580C] dark:text-white flex items-center justify-center">
|
||
<FileText className="w-5 h-5" />
|
||
</div>
|
||
<div className="flex flex-col justify-center">
|
||
<p className="text-[13px] text-black dark:text-white line-clamp-1">
|
||
{title}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
})}
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AssistantSteps;
|