/
alexefan136
/
flowstack
Обзор
Документация
Войти
/
alexefan136
/
flowstack
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
ui/src/components/knowledge/KnowledgeSearch.tsx
435 строк
15 KB
Alexander Efanov
upd fix
04 авг 2026, 14:09
04 авг 2026, 14:09
f63ab86
Код
Авторство
О чём код?
// src/components/knowledge/KnowledgeSearch.tsx import { Check, Copy, FileText, Hash, Loader2, Search, Sparkles, } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import { useParams } from "react-router-dom"; import remarkGfm from "remark-gfm"; import type { KnowledgeSource } from "../../lib/api"; import { searchKnowledge } from "../../lib/api"; import { useAppStore } from "../../lib/stores/app-store"; import { cn } from "../../lib/utils"; // ============================================================================ // Constants // ============================================================================ const MARKDOWN_STYLES = ` text-sm leading-relaxed [&_h1]:text-base [&_h1]:font-bold [&_h1]:text-white [&_h1]:mt-3 [&_h1]:mb-2 [&_h2]:text-sm [&_h2]:font-bold [&_h2]:text-white [&_h2]:mt-2 [&_h2]:mb-1.5 [&_h3]:text-sm [&_h3]:font-semibold [&_h3]:text-white/90 [&_h3]:mt-2 [&_h3]:mb-1 [&_p]:text-white/80 [&_p]:mb-2 [&_strong]:text-white [&_strong]:font-semibold [&_em]:italic [&_em]:text-white/70 [&_code]:text-accent-lime [&_code]:bg-black/30 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono [&_pre]:bg-black/40 [&_pre]:border [&_pre]:border-white/10 [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:overflow-x-auto [&_pre]:my-2 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-white/85 [&_a]:text-accent-cyan [&_a]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:space-y-1 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:space-y-1 [&_li]:text-white/80 [&_blockquote]:border-l-2 [&_blockquote]:border-accent-lime [&_blockquote]:pl-3 [&_blockquote]:text-white/60 [&_blockquote]:my-2 [&_table]:w-full [&_table]:text-xs [&_table]:my-2 [&_th]:text-left [&_th]:font-medium [&_th]:text-white/90 [&_th]:border-b [&_th]:border-white/10 [&_th]:py-1.5 [&_th]:pr-3 [&_td]:text-white/75 [&_td]:border-b [&_td]:border-white/10 [&_td]:py-1.5 [&_td]:pr-3 [&_hr]:border-white/10 [&_hr]:my-3 `; const SUGGESTIONS = [ "Что такое RAG?", "Как работает векторный поиск?", "Архитектура системы", "API документация", ]; const CONTENT_COLLAPSE_THRESHOLD = 500; // ============================================================================ // Result Card // ============================================================================ function ResultCard({ source, rank, isCopied, onCopy, }: { source: KnowledgeSource; rank: number; isCopied: boolean; onCopy: () => void; }) { const [isExpanded, setIsExpanded] = useState(false); const title = source.metadata?.title ? String(source.metadata.title) : source.source || "Без названия"; const scorePct = Math.round(source.score * 100); const rerankPct = source.rerank_score ? Math.round(source.rerank_score * 100) : null; const isLong = source.content.length > CONTENT_COLLAPSE_THRESHOLD; return ( <div className="glass rounded-xl p-4 hover:border-border-accent transition-all group"> {/* Header */} <div className="flex items-start justify-between gap-3 mb-3"> <div className="flex items-start gap-3 flex-1 min-w-0"> <div className="w-8 h-8 rounded-lg bg-accent-lime-soft flex items-center justify-center shrink-0"> <span className="text-xs font-bold text-accent-lime">#{rank}</span> </div> <div className="flex-1 min-w-0"> <h3 className="text-sm font-medium text-text-primary mb-1 flex items-center gap-2 truncate"> <FileText className="w-3.5 h-3.5 text-text-muted shrink-0" aria-hidden="true" /> {title} </h3> <div className="flex items-center gap-3 text-xs text-text-muted flex-wrap"> <span className="flex items-center gap-1.5"> <div className="w-16 h-1.5 bg-(--glass-bg-strong) rounded-full overflow-hidden"> <div className="h-full rounded-full bg-accent-lime transition-all" style={{ width: `${Math.min(scorePct, 100)}%` }} /> </div> <span className="text-accent-lime font-medium tabular-nums"> {scorePct}% </span> </span> {rerankPct !== null && ( <span className="flex items-center gap-1"> <span className="text-text-muted/60">Rerank:</span> <span className="text-accent-cyan font-medium tabular-nums"> {rerankPct}% </span> </span> )} </div> </div> </div> <button onClick={onCopy} className={cn( "btn btn-ghost btn-icon btn-sm shrink-0 transition-opacity", isCopied ? "text-accent-lime opacity-100" : "opacity-0 group-hover:opacity-100", )} title={isCopied ? "Скопировано" : "Копировать"} aria-label="Копировать содержимое" > {isCopied ? ( <Check className="w-4 h-4" /> ) : ( <Copy className="w-4 h-4" /> )} </button> </div> {/* Content */} <div className="bg-(--glass-bg-strong) border border-border-subtle rounded-lg p-3 overflow-x-auto"> <div className={cn( MARKDOWN_STYLES, !isExpanded && isLong && "line-clamp-6", )} > <ReactMarkdown remarkPlugins={[remarkGfm]}> {source.content} </ReactMarkdown> </div> {isLong && ( <button onClick={() => setIsExpanded(!isExpanded)} className="text-xs text-accent-lime hover:text-accent-lime/80 mt-2 transition" > {isExpanded ? "Свернуть ↑" : `Показать полностью (${source.content.length.toLocaleString()} симв.) ↓`} </button> )} </div> {/* Metadata */} {source.metadata && Object.keys(source.metadata).length > 1 && ( <details className="mt-3 group/details"> <summary className="text-xs text-text-muted cursor-pointer hover:text-text-secondary transition flex items-center gap-1.5"> <Hash className="w-3 h-3" aria-hidden="true" /> Метаданные <span className="group-open/details:rotate-90 transition-transform inline-block"> › </span> </summary> <div className="mt-2 space-y-1 pl-1"> {Object.entries(source.metadata).map(([key, value]) => { if (key === "title") return null; return ( <div key={key} className="flex gap-2 text-xs"> <span className="text-text-muted font-medium shrink-0 w-24"> {key}: </span> <span className="text-text-secondary break-all"> {value != null ? String(value) : "—"} </span> </div> ); })} </div> </details> )} </div> ); } // ============================================================================ // Main Component // ============================================================================ export function KnowledgeSearch() { const { workspace = "default" } = useParams(); const { toast } = useAppStore(); const [query, setQuery] = useState(""); const [results, setResults] = useState<KnowledgeSource[]>([]); const [isSearching, setIsSearching] = useState(false); const [searchPerformed, setSearchPerformed] = useState(false); const [copiedId, setCopiedId] = useState<string | null>(null); const inputRef = useRef<HTMLInputElement>(null); // ========================================================================== // Search // ========================================================================== const handleSearch = useCallback(async () => { if (!query.trim() || !workspace) return; setIsSearching(true); setSearchPerformed(true); try { const response = await searchKnowledge(query, workspace, 10); setResults(response.results || []); } catch (error) { console.error("[KnowledgeSearch] Search error:", error); setResults([]); toast( error instanceof Error ? error.message : "Ошибка поиска", "warning", ); } finally { setIsSearching(false); } }, [query, workspace, toast]); const handleKeyDown = useCallback( (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSearch(); } }, [handleSearch], ); const handleCopy = useCallback( async (content: string, id: string) => { try { await navigator.clipboard.writeText(content); setCopiedId(id); toast("Скопировано", "success"); setTimeout(() => setCopiedId(null), 2000); } catch { toast("Не удалось скопировать", "warning"); } }, [toast], ); // ========================================================================== // Stats // ========================================================================== const stats = useMemo(() => { if (results.length === 0) return null; const avgScore = results.reduce((sum, r) => sum + r.score, 0) / results.length; return { count: results.length, avgScore: Math.round(avgScore * 100), topScore: Math.round(Math.max(...results.map((r) => r.score)) * 100), }; }, [results]); // ========================================================================== // Render // ========================================================================== return ( <div className="space-y-4 fadein"> {/* Search Input */} <div className="glass rounded-xl p-4"> <div className="flex gap-2 mb-3"> <div className="relative flex-1"> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" aria-hidden="true" /> <input ref={inputRef} type="text" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={handleKeyDown} placeholder="Задайте вопрос по базе знаний..." disabled={isSearching} className="input pl-9 w-full" autoFocus /> </div> <button onClick={handleSearch} disabled={isSearching || !query.trim()} className="btn btn-primary shrink-0" > {isSearching ? ( <> <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> Ищем... </> ) : ( <> <Sparkles className="w-4 h-4" aria-hidden="true" /> Найти </> )} </button> </div> {/* Suggestions */} {!searchPerformed && ( <div> <div className="text-xs text-text-muted mb-2">Попробуйте:</div> <div className="flex flex-wrap gap-2"> {SUGGESTIONS.map((s) => ( <button key={s} onClick={() => { setQuery(s); setTimeout(() => inputRef.current?.focus(), 0); }} className="btn btn-ghost btn-sm" > {s} </button> ))} </div> </div> )} </div> {/* Stats */} {stats && searchPerformed && !isSearching && ( <div className="grid grid-cols-3 gap-3"> <div className="glass rounded-xl p-3"> <div className="text-xs text-text-muted mb-1">Найдено</div> <div className="text-xl font-bold text-text-primary"> {stats.count} </div> </div> <div className="glass rounded-xl p-3"> <div className="text-xs text-text-muted mb-1"> Средняя релевантность </div> <div className="text-xl font-bold text-accent-lime"> {stats.avgScore}% </div> </div> <div className="glass rounded-xl p-3"> <div className="text-xs text-text-muted mb-1">Лучший результат</div> <div className="text-xl font-bold text-accent-cyan"> {stats.topScore}% </div> </div> </div> )} {/* Loading */} {isSearching && ( <div className="flex flex-col items-center justify-center py-16"> <div className="relative"> <div className="w-14 h-14 rounded-full border-2 border-accent-lime/20" /> <Loader2 className="w-7 h-7 text-accent-lime animate-spin absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" aria-hidden="true" /> </div> <p className="text-sm text-text-muted mt-4"> Семантический поиск по базе знаний... </p> </div> )} {/* Results */} {searchPerformed && !isSearching && results.length > 0 && ( <div className="space-y-3"> <div className="text-sm text-text-secondary flex items-center gap-2"> <Sparkles className="w-4 h-4 text-accent-lime" aria-hidden="true" /> Найдено {results.length}{" "} {results.length === 1 ? "результат" : results.length < 5 ? "результата" : "результатов"} </div> {results.map((source, index) => ( <ResultCard key={source.chunk_id || index} source={source} rank={index + 1} isCopied={copiedId === source.chunk_id} onCopy={() => handleCopy(source.content, source.chunk_id)} /> ))} </div> )} {/* Empty State */} {searchPerformed && !isSearching && results.length === 0 && ( <div className="flex flex-col items-center justify-center py-16 text-center"> <div className="w-16 h-16 rounded-full bg-(--glass-bg-default) flex items-center justify-center mx-auto mb-4"> <Search className="w-8 h-8 text-text-muted" aria-hidden="true" /> </div> <h3 className="text-lg font-semibold text-text-primary mb-2"> Ничего не найдено </h3> <p className="text-sm text-text-muted max-w-md mb-4"> Попробуйте переформулировать запрос или загрузить больше документов в базу знаний. </p> <button onClick={() => { setQuery(""); setSearchPerformed(false); setResults([]); inputRef.current?.focus(); }} className="btn btn-ghost btn-sm" > Очистить поиск </button> </div> )} </div> ); }