agentscope / examples /web_ui /frontend /src /components /drawer /KnowledgeSearchDrawer.tsx
ghostdrive1's picture
Upload folder using huggingface_hub
0b9dc2e verified
Raw
History Blame Contribute Delete
5.9 kB
import { FlaskConical, Loader2, Search } from 'lucide-react';
import { useState } from 'react';
import type { VectorSearchResult } from '@/api';
import { Badge } from '@/components/ui/badge.tsx';
import { Button } from '@/components/ui/button.tsx';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty.tsx';
import { Input } from '@/components/ui/input.tsx';
import { Label } from '@/components/ui/label.tsx';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet.tsx';
import { Textarea } from '@/components/ui/textarea.tsx';
import { useKnowledgeBases } from '@/hooks/useKnowledgeBases';
import { useTranslation } from '@/i18n/useI18n.ts';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
knowledgeBaseId: string;
knowledgeBaseName: string;
}
/**
* Right-side drawer for ad-hoc retrieval testing on a knowledge base.
* The user types a query, picks `top_k`, and inspects ranked chunks.
*/
export function KnowledgeSearchDrawer({
open,
onOpenChange,
knowledgeBaseId,
knowledgeBaseName,
}: Props) {
const { t } = useTranslation();
const { search } = useKnowledgeBases();
const [query, setQuery] = useState('');
const [topK, setTopK] = useState(5);
const [loading, setLoading] = useState(false);
const [results, setResults] = useState<VectorSearchResult[] | null>(null);
const [error, setError] = useState<string | null>(null);
const handleSearch = async () => {
const trimmed = query.trim();
if (!trimmed) return;
setLoading(true);
setError(null);
try {
const res = await search(knowledgeBaseId, {
query: trimmed,
top_k: topK,
});
setResults(res.results);
} catch (e) {
setError((e as Error).message || String(e));
setResults(null);
} finally {
setLoading(false);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full sm:!max-w-[480px] flex-col gap-y-4 p-4">
<SheetHeader className="px-0">
<SheetTitle className="flex items-center gap-x-2">
<FlaskConical className="size-4" />
{t('knowledge.test.title')}
</SheetTitle>
<SheetDescription className="truncate">
{t('knowledge.test.description', { name: knowledgeBaseName })}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-y-3 px-0">
<div className="flex flex-col gap-y-1.5">
<Label htmlFor="kb-test-query" className="text-xs">
{t('knowledge.test.queryLabel')}
</Label>
<Textarea
id="kb-test-query"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('knowledge.test.queryPlaceholder')}
rows={3}
disabled={loading}
/>
</div>
<div className="flex items-center gap-x-3">
<div className="flex items-center gap-x-2">
<Label htmlFor="kb-test-topk" className="text-xs">
{t('knowledge.test.topKLabel')}
</Label>
<Input
id="kb-test-topk"
type="number"
min={1}
max={50}
value={topK}
onChange={(e) =>
setTopK(Math.max(1, Math.min(50, Number(e.target.value) || 1)))
}
className="w-20"
disabled={loading}
/>
</div>
<Button
className="ml-auto"
size="sm"
onClick={handleSearch}
disabled={loading || !query.trim()}
>
{loading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Search className="size-3.5" />
)}
{t('knowledge.test.searchButton')}
</Button>
</div>
{error && <p className="text-destructive text-sm">{error}</p>}
</div>
<div className="flex-1 overflow-y-auto px-0">
{results === null ? null : results.length === 0 ? (
<Empty className="border-none py-4">
<EmptyHeader>
<EmptyMedia variant="icon">
<Search />
</EmptyMedia>
<EmptyTitle>{t('knowledge.test.emptyTitle')}</EmptyTitle>
<EmptyDescription>
{t('knowledge.test.emptyDescription')}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="flex flex-col gap-y-3">
{results.map((hit, idx) => (
<ResultCard
key={`${hit.document_id}-${idx}`}
hit={hit}
index={idx}
/>
))}
</div>
)}
</div>
<SheetFooter className="px-0">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t('common.close')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
function ResultCard({ hit, index }: { hit: VectorSearchResult; index: number }) {
const { t } = useTranslation();
const text =
hit.chunk.content && typeof hit.chunk.content === 'object' && 'text' in hit.chunk.content
? String(hit.chunk.content.text ?? '')
: JSON.stringify(hit.chunk.content);
return (
<div className="rounded-md border bg-card p-3 flex flex-col gap-y-2">
<div className="flex items-center gap-x-2 text-xs text-muted-foreground">
<Badge variant="secondary" className="font-mono">
#{index + 1}
</Badge>
<Badge variant="outline" className="font-mono">
{t('knowledge.test.score')}: {hit.score.toFixed(4)}
</Badge>
<span className="ml-auto truncate" title={hit.chunk.source}>
{hit.chunk.source}
</span>
</div>
<p className="text-sm whitespace-pre-wrap break-words">{text}</p>
<div className="text-xs text-muted-foreground">
{t('knowledge.test.chunkPosition', {
index: hit.chunk.chunk_index + 1,
total: hit.chunk.total_chunks,
})}
</div>
</div>
);
}