|
@@ -1,8 +1,420 @@
|
|
|
|
|
+import { useState } from 'react'
|
|
|
|
|
+import { useQuery } from '@tanstack/react-query'
|
|
|
|
|
+import { Plus, Search } from 'lucide-react'
|
|
|
|
|
+import { api } from '@/api/client'
|
|
|
|
|
+import { useAuthStore } from '@/stores/authStore'
|
|
|
|
|
+import { useToastStore } from '@/stores/toastStore'
|
|
|
|
|
+import { JsonEditor } from '@/components/JsonEditor'
|
|
|
|
|
+import { ApiError } from '@/types'
|
|
|
|
|
+import type { CollectionMeta, SearchResult } from '@/types'
|
|
|
|
|
+
|
|
|
|
|
+// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function isEmptyObject(json: string): boolean {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(json) as unknown
|
|
|
|
|
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return false
|
|
|
|
|
+ return Object.keys(parsed as Record<string, unknown>).length === 0
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function omitVector(data: Record<string, unknown>): Record<string, unknown> {
|
|
|
|
|
+ const result: Record<string, unknown> = {}
|
|
|
|
|
+ for (const key of Object.keys(data)) {
|
|
|
|
|
+ if (key !== '_vector') {
|
|
|
|
|
+ result[key] = data[key]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return result
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── collection picker (vector-only) ─────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+interface CollectionPickerProps {
|
|
|
|
|
+ project: string
|
|
|
|
|
+ selected: string
|
|
|
|
|
+ onSelect: (coll: string) => void
|
|
|
|
|
+ collections: CollectionMeta[]
|
|
|
|
|
+ isLoading: boolean
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function CollectionPicker({ selected, onSelect, collections, isLoading }: CollectionPickerProps) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
|
|
+ <label className="text-xs font-medium text-slate-400 shrink-0">Collection</label>
|
|
|
|
|
+ <select
|
|
|
|
|
+ value={selected}
|
|
|
|
|
+ onChange={(e) => onSelect(e.target.value)}
|
|
|
|
|
+ disabled={isLoading || collections.length === 0}
|
|
|
|
|
+ className="rounded-md bg-slate-800 border border-slate-600 text-slate-200 text-sm px-3 py-1.5 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40 disabled:opacity-50"
|
|
|
|
|
+ data-testid="rag-collection-picker"
|
|
|
|
|
+ >
|
|
|
|
|
+ {isLoading && <option value="">Loading…</option>}
|
|
|
|
|
+ {!isLoading && collections.length === 0 && (
|
|
|
|
|
+ <option value="">No vector collections</option>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {collections.map((c) => (
|
|
|
|
|
+ <option key={c.name} value={c.name}>
|
|
|
|
|
+ {c.name}
|
|
|
|
|
+ </option>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── add item section ──────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+interface AddItemSectionProps {
|
|
|
|
|
+ project: string
|
|
|
|
|
+ collection: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function AddItemSection({ project, collection }: AddItemSectionProps) {
|
|
|
|
|
+ const push = useToastStore((s) => s.push)
|
|
|
|
|
+
|
|
|
|
|
+ const [text, setText] = useState('')
|
|
|
|
|
+ const [metaJson, setMetaJson] = useState('{}')
|
|
|
|
|
+ const [metaValid, setMetaValid] = useState(true)
|
|
|
|
|
+ const [showMeta, setShowMeta] = useState(false)
|
|
|
|
|
+ const [submitting, setSubmitting] = useState(false)
|
|
|
|
|
+
|
|
|
|
|
+ const handleSubmit = async (e: React.FormEvent) => {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ if (!text.trim()) {
|
|
|
|
|
+ push('Text is required.', 'error')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (showMeta && !metaValid) {
|
|
|
|
|
+ push('Metadata JSON is invalid.', 'error')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setSubmitting(true)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const body: { text: string; metadata?: Record<string, unknown> } = { text: text.trim() }
|
|
|
|
|
+ if (showMeta && !isEmptyObject(metaJson)) {
|
|
|
|
|
+ body.metadata = JSON.parse(metaJson) as Record<string, unknown>
|
|
|
|
|
+ }
|
|
|
|
|
+ const result = await api.storeVector(project, collection, body)
|
|
|
|
|
+ push(`Vector stored (id: ${result.id}).`, 'success')
|
|
|
|
|
+ setText('')
|
|
|
|
|
+ setMetaJson('{}')
|
|
|
|
|
+ setShowMeta(false)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof ApiError ? err.message : String(err)
|
|
|
|
|
+ push(`Store failed: ${msg}`, 'error')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setSubmitting(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <section className="rounded-xl border border-slate-700/40 bg-slate-800/30 p-5 flex flex-col gap-4">
|
|
|
|
|
+ <h2 className="text-base font-semibold text-slate-200">Add Item</h2>
|
|
|
|
|
+
|
|
|
|
|
+ <form onSubmit={(e) => void handleSubmit(e)} className="flex flex-col gap-4">
|
|
|
|
|
+ {/* Text input */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="block text-xs font-medium text-slate-400 mb-1">
|
|
|
|
|
+ Text <span className="text-red-400">*</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ value={text}
|
|
|
|
|
+ onChange={(e) => setText(e.target.value)}
|
|
|
|
|
+ rows={4}
|
|
|
|
|
+ placeholder="Enter text to embed and store…"
|
|
|
|
|
+ className="w-full rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40 resize-y"
|
|
|
|
|
+ data-testid="rag-text-input"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Metadata toggle */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => setShowMeta((v) => !v)}
|
|
|
|
|
+ className="text-xs text-indigo-400 hover:text-indigo-300 transition"
|
|
|
|
|
+ >
|
|
|
|
|
+ {showMeta ? '− Hide metadata' : '+ Add metadata (optional)'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+
|
|
|
|
|
+ {showMeta && (
|
|
|
|
|
+ <div className="mt-2">
|
|
|
|
|
+ <label className="block text-xs font-medium text-slate-400 mb-1">
|
|
|
|
|
+ Metadata JSON <span className="text-slate-500">(optional)</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <JsonEditor
|
|
|
|
|
+ value={metaJson}
|
|
|
|
|
+ onChange={setMetaJson}
|
|
|
|
|
+ onValidityChange={setMetaValid}
|
|
|
|
|
+ height={140}
|
|
|
|
|
+ />
|
|
|
|
|
+ {!metaValid && (
|
|
|
|
|
+ <p className="mt-1 text-xs text-red-400">Invalid JSON.</p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Submit */}
|
|
|
|
|
+ <div className="flex justify-end">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="submit"
|
|
|
|
|
+ disabled={submitting || !text.trim() || (showMeta && !metaValid)}
|
|
|
|
|
+ className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
+ data-testid="rag-add-btn"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Plus size={15} />
|
|
|
|
|
+ {submitting ? 'Storing…' : 'Store Vector'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── search section ────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+interface SearchSectionProps {
|
|
|
|
|
+ project: string
|
|
|
|
|
+ collection: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function SearchSection({ project, collection }: SearchSectionProps) {
|
|
|
|
|
+ const push = useToastStore((s) => s.push)
|
|
|
|
|
+
|
|
|
|
|
+ const [queryText, setQueryText] = useState('')
|
|
|
|
|
+ const [topK, setTopK] = useState('5')
|
|
|
|
|
+ const [minScore, setMinScore] = useState('0')
|
|
|
|
|
+ const [searching, setSearching] = useState(false)
|
|
|
|
|
+ const [results, setResults] = useState<SearchResult[] | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+ const handleSearch = async (e: React.FormEvent) => {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ if (!queryText.trim()) {
|
|
|
|
|
+ push('Query text is required.', 'error')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setSearching(true)
|
|
|
|
|
+ setResults(null)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const topKNum = parseInt(topK, 10)
|
|
|
|
|
+ const minScoreNum = parseFloat(minScore)
|
|
|
|
|
+ const response = await api.search(project, collection, {
|
|
|
|
|
+ query_text: queryText.trim(),
|
|
|
|
|
+ top_k: isNaN(topKNum) ? 5 : topKNum,
|
|
|
|
|
+ min_score: isNaN(minScoreNum) ? 0 : minScoreNum,
|
|
|
|
|
+ })
|
|
|
|
|
+ setResults(response.results)
|
|
|
|
|
+ if (response.results.length === 0) {
|
|
|
|
|
+ push('No results found.', 'info')
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof ApiError ? err.message : String(err)
|
|
|
|
|
+ push(`Search failed: ${msg}`, 'error')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setSearching(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const inputClass =
|
|
|
|
|
+ 'rounded-md bg-slate-800 border border-slate-600 text-slate-200 text-sm px-3 py-1.5 placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40'
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <section className="rounded-xl border border-slate-700/40 bg-slate-800/30 p-5 flex flex-col gap-4">
|
|
|
|
|
+ <h2 className="text-base font-semibold text-slate-200">Search</h2>
|
|
|
|
|
+
|
|
|
|
|
+ <form onSubmit={(e) => void handleSearch(e)} className="flex flex-col gap-4">
|
|
|
|
|
+ {/* Query text */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="block text-xs font-medium text-slate-400 mb-1">
|
|
|
|
|
+ Query <span className="text-red-400">*</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={queryText}
|
|
|
|
|
+ onChange={(e) => setQueryText(e.target.value)}
|
|
|
|
|
+ placeholder="Enter search query…"
|
|
|
|
|
+ className={`${inputClass} w-full`}
|
|
|
|
|
+ data-testid="rag-query-input"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Parameters row */}
|
|
|
|
|
+ <div className="flex flex-wrap items-center gap-4">
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <label className="text-xs font-medium text-slate-400 shrink-0">top_k</label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="number"
|
|
|
|
|
+ value={topK}
|
|
|
|
|
+ onChange={(e) => setTopK(e.target.value)}
|
|
|
|
|
+ min={1}
|
|
|
|
|
+ max={100}
|
|
|
|
|
+ className={`${inputClass} w-20`}
|
|
|
|
|
+ data-testid="rag-topk-input"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <label className="text-xs font-medium text-slate-400 shrink-0">min_score</label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="number"
|
|
|
|
|
+ value={minScore}
|
|
|
|
|
+ onChange={(e) => setMinScore(e.target.value)}
|
|
|
|
|
+ min={0}
|
|
|
|
|
+ max={1}
|
|
|
|
|
+ step={0.01}
|
|
|
|
|
+ className={`${inputClass} w-24`}
|
|
|
|
|
+ data-testid="rag-minscore-input"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="submit"
|
|
|
|
|
+ disabled={searching || !queryText.trim()}
|
|
|
|
|
+ className="flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
+ data-testid="rag-search-btn"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Search size={14} />
|
|
|
|
|
+ {searching ? 'Searching…' : 'Search'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Results */}
|
|
|
|
|
+ {results !== null && (
|
|
|
|
|
+ <div className="flex flex-col gap-3 mt-2">
|
|
|
|
|
+ <p className="text-xs text-slate-400">
|
|
|
|
|
+ {results.length} result{results.length !== 1 ? 's' : ''}
|
|
|
|
|
+ </p>
|
|
|
|
|
+
|
|
|
|
|
+ {results.length === 0 ? (
|
|
|
|
|
+ <div className="rounded-xl border border-slate-700/40 bg-slate-800/20 px-6 py-8 text-center">
|
|
|
|
|
+ <p className="text-slate-400 text-sm">No results above the score threshold.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="flex flex-col gap-3">
|
|
|
|
|
+ {results.map((result, idx) => {
|
|
|
|
|
+ const dataWithoutVector = omitVector(result.data)
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={`${result.id}-${idx}`}
|
|
|
|
|
+ className="rounded-xl border border-slate-700/40 bg-slate-900/40 p-4 flex flex-col gap-2"
|
|
|
|
|
+ data-testid="rag-result-item"
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* Header row */}
|
|
|
|
|
+ <div className="flex items-center gap-3 flex-wrap">
|
|
|
|
|
+ <span className="text-xs font-mono text-slate-400">
|
|
|
|
|
+ #{idx + 1}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span
|
|
|
|
|
+ className="text-sm font-semibold text-indigo-300"
|
|
|
|
|
+ data-testid="rag-result-score"
|
|
|
|
|
+ >
|
|
|
|
|
+ score: {result.score.toFixed(4)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span className="text-xs font-mono text-slate-500">
|
|
|
|
|
+ id: {result.id}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Data preview */}
|
|
|
|
|
+ <pre className="text-xs font-mono text-slate-300 bg-slate-800/60 rounded-md p-3 overflow-x-auto whitespace-pre-wrap break-words">
|
|
|
|
|
+ {JSON.stringify(dataWithoutVector, null, 2)}
|
|
|
|
|
+ </pre>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </section>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── page ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
export default function RagTester() {
|
|
export default function RagTester() {
|
|
|
|
|
+ const currentProject = useAuthStore((s) => s.currentProject)
|
|
|
|
|
+
|
|
|
|
|
+ const [selectedColl, setSelectedColl] = useState('')
|
|
|
|
|
+
|
|
|
|
|
+ // Fetch all collections, filter to vector only
|
|
|
|
|
+ const { data: collectionsData, isLoading: collectionsLoading } = useQuery<{ collections: CollectionMeta[] }>({
|
|
|
|
|
+ queryKey: ['collections', currentProject],
|
|
|
|
|
+ queryFn: () => api.listCollections(currentProject!),
|
|
|
|
|
+ enabled: !!currentProject,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const vectorCollections = (collectionsData?.collections ?? []).filter((c) => c.kind === 'vector')
|
|
|
|
|
+ const effectiveColl = selectedColl || vectorCollections[0]?.name || ''
|
|
|
|
|
+
|
|
|
|
|
+ // ─── guard: no project ────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ if (!currentProject) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex flex-col gap-8">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h1 className="text-2xl font-semibold text-slate-100">RAG Tester</h1>
|
|
|
|
|
+ <p className="text-slate-400 text-sm mt-1">Store and search vectors.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-10 text-center">
|
|
|
|
|
+ <p className="text-slate-400 text-sm">
|
|
|
|
|
+ No project selected — choose a project from the selector above.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ─── render ───────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
- <div>
|
|
|
|
|
- <h1 className="text-2xl font-semibold text-slate-100 mb-2">RAG Tester</h1>
|
|
|
|
|
- <p className="text-slate-400">Vector search tester — coming in W8.</p>
|
|
|
|
|
|
|
+ <div className="flex flex-col gap-6">
|
|
|
|
|
+ {/* Page header */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h1 className="text-2xl font-semibold text-slate-100">RAG Tester</h1>
|
|
|
|
|
+ <p className="text-slate-400 text-sm mt-1">
|
|
|
|
|
+ Project: <span className="text-slate-300 font-mono">{currentProject}</span>
|
|
|
|
|
+ {!collectionsLoading && (
|
|
|
|
|
+ <span className="ml-2 text-slate-500">
|
|
|
|
|
+ — {vectorCollections.length} vector collection{vectorCollections.length !== 1 ? 's' : ''}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Collection selector */}
|
|
|
|
|
+ <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-4 py-3">
|
|
|
|
|
+ <CollectionPicker
|
|
|
|
|
+ project={currentProject}
|
|
|
|
|
+ selected={effectiveColl}
|
|
|
|
|
+ onSelect={setSelectedColl}
|
|
|
|
|
+ collections={vectorCollections}
|
|
|
|
|
+ isLoading={collectionsLoading}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* No vector collections notice */}
|
|
|
|
|
+ {!collectionsLoading && vectorCollections.length === 0 && (
|
|
|
|
|
+ <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-10 text-center">
|
|
|
|
|
+ <p className="text-slate-400 text-sm">
|
|
|
|
|
+ No vector collections in this project. Create a collection with kind
|
|
|
|
|
+ <span className="font-mono text-indigo-300">vector</span> to use the RAG tester.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Add + Search sections (only when a collection is selected) */}
|
|
|
|
|
+ {effectiveColl && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <AddItemSection project={currentProject} collection={effectiveColl} />
|
|
|
|
|
+ <SearchSection project={currentProject} collection={effectiveColl} />
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|