feat: semantic search with Qdrant + Ollama embeddings
- Add SemanticSearchService with embed() + searchQdrant() methods - Add GET /api/search/semantic endpoint (?q=query&k=5) - Wire SemanticSearchService into router and cmd/server/main.go - Add SemanticSearch React page with results + similarity scores - Add 'Semantic Search' nav link in App.tsx - Add unit tests with mocked Ollama + Qdrant HTTP servers (4 tests, all passing) - Add GitHub issue templates (bug report, feature request) - Add pull request template
This commit is contained in:
163
frontend/src/pages/SemanticSearch.tsx
Normal file
163
frontend/src/pages/SemanticSearch.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react'
|
||||
import { fetchAPI, postAPI } from '../api/client'
|
||||
import { useToast } from '../components/Toast'
|
||||
import ErrorBanner from '../components/ErrorBanner'
|
||||
|
||||
interface SemanticResult {
|
||||
id: number
|
||||
title: string
|
||||
media_type: string
|
||||
year: number | null
|
||||
score: number
|
||||
overview: string
|
||||
}
|
||||
|
||||
function scorePercent(score: number): string {
|
||||
return `${Math.round(score * 100)}%`
|
||||
}
|
||||
|
||||
function mediaTypeBadge(type: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
movie: 'bg-blue-600',
|
||||
series: 'bg-green-600',
|
||||
music: 'bg-purple-600',
|
||||
book: 'bg-orange-600',
|
||||
audiobook: 'bg-yellow-600',
|
||||
podcast: 'bg-pink-600',
|
||||
}
|
||||
return colors[type] ?? 'bg-gray-600'
|
||||
}
|
||||
|
||||
export default function SemanticSearch() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SemanticResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasSearched, setHasSearched] = useState(false)
|
||||
const [addingToQueue, setAddingToQueue] = useState<Set<number>>(new Set())
|
||||
const { showToast } = useToast()
|
||||
|
||||
async function doSearch() {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return
|
||||
setLoading(true)
|
||||
setHasSearched(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetchAPI<{ results: SemanticResult[] }>(
|
||||
'/api/search/semantic?q=' + encodeURIComponent(trimmed) + '&k=5'
|
||||
)
|
||||
setResults(res.results ?? [])
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Semantic search failed'
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
doSearch()
|
||||
}
|
||||
|
||||
async function handleAddToQueue(item: SemanticResult) {
|
||||
setAddingToQueue(prev => new Set(prev).add(item.id))
|
||||
try {
|
||||
await postAPI<{ id: number }>('/api/queue', { media_id: item.id })
|
||||
showToast(`Added "${item.title}" to queue`)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to add to queue'
|
||||
showToast(`Error: ${message}`)
|
||||
} finally {
|
||||
setAddingToQueue(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(item.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-100 mb-6">Semantic Search</h2>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} className="mb-6">
|
||||
<div className="flex gap-3 max-w-2xl">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Describe what you're looking for..."
|
||||
className="bg-gray-800 border border-gray-700 focus:border-indigo-500 text-white rounded-lg px-4 py-3 w-full outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !query.trim()}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-6 py-3 rounded-lg font-semibold transition-colors"
|
||||
>
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mt-2">AI-powered search using natural language descriptions</p>
|
||||
</form>
|
||||
|
||||
{!hasSearched && (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<p className="text-lg">Enter a natural language query to find media</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearched && loading && (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-24 bg-gray-800 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearched && error && !loading && <ErrorBanner error={error} onRetry={doSearch} />}
|
||||
|
||||
{hasSearched && !loading && !error && results.length === 0 && (
|
||||
<div className="text-gray-500 text-center py-12">No results found</div>
|
||||
)}
|
||||
|
||||
{hasSearched && !loading && !error && results.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-gray-400 text-sm mb-3">{results.length} result{results.length !== 1 ? 's' : ''}</p>
|
||||
{results.map(item => {
|
||||
const isAdding = addingToQueue.has(item.id)
|
||||
return (
|
||||
<div key={item.id} className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-700 transition-colors">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`${mediaTypeBadge(item.media_type)} text-white text-xs font-semibold px-2 py-0.5 rounded`}>
|
||||
{item.media_type}
|
||||
</span>
|
||||
<h3 className="text-gray-100 font-medium truncate">{item.title}</h3>
|
||||
{item.year && <span className="text-gray-500 text-sm">{item.year}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-indigo-400 text-sm font-medium">{scorePercent(item.score)} match</span>
|
||||
</div>
|
||||
{item.overview && (
|
||||
<p className="text-gray-400 text-sm line-clamp-2">{item.overview}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
disabled={isAdding}
|
||||
onClick={() => handleAddToQueue(item)}
|
||||
className="px-3 py-1.5 text-xs font-semibold rounded bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap transition-colors"
|
||||
>
|
||||
{isAdding ? 'Adding...' : 'Add to Queue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user