YAS/apps/web/src/ai/SearchBar.tsx
YetAnotherSuite Dev 0afbae8944 feat: Phase 4 AI intelligence — writing assistant, semantic search, knowledge graph
Phase 4.1: AI Writing Assistant
- GPT-4o integration with OpenAI API (fallback to local parsing)
- Six AI actions: summarize, expand, rewrite, extract actions,
  suggest links, smart schedule
- TipTap inline AI assistant panel with streaming-ready architecture
- Apply/dismiss UI for AI suggestions
- Rewrite style selector (concise, formal, simpler, detailed)

Phase 4.2: Natural Language Input
- /api/v1/natural-language endpoint for parsing unstructured text
- AI-powered extraction of type, title, date, priority, participants
- Local regex fallback when no API key configured
- Priority parsing (!1-5) and date detection (tomorrow, next week)

Phase 4.3: Knowledge Graph
- D3.js-ready graph visualization container
- Zoom in/out/fit controls
- Placeholder state with guidance for content creation
- Data model for nodes, links, and groups

Phase 4.4: Semantic Search Infrastructure
- Unified search bar with debounced input (300ms)
- Type-based result icons (note, task, event)
- Search result display with title and content preview
- pgvector and embedding pipeline data model in schema
2026-07-20 22:22:54 +02:00

86 lines
3.0 KiB
TypeScript

import { useState, useCallback, useRef } from 'react';
import { Search, Loader2, FileText, CheckSquare, Calendar } from 'lucide-react';
import { useDebounce } from '@yetanother/hooks';
interface SearchResult {
id: string;
title: string;
type: string;
plainText: string;
updatedAt: string;
}
export function SearchBar() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const debouncedQuery = useDebounce(query, 300);
const inputRef = useRef<HTMLInputElement>(null);
const handleSearch = useCallback(async (q: string) => {
if (!q.trim()) { setResults([]); return; }
setLoading(true);
try {
const res = await fetch(`/api/v1/search?q=${encodeURIComponent(q)}`);
const data = await res.json() as { results: SearchResult[] };
setResults(data.results);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, []);
useState(() => {
if (debouncedQuery) handleSearch(debouncedQuery);
});
const typeIcons: Record<string, React.ReactNode> = {
note: <FileText className="size-4" />,
task: <CheckSquare className="size-4" />,
event: <Calendar className="size-4" />,
};
return (
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 200)}
placeholder="Search anything..."
className="w-64 pl-9 pr-3 py-2 text-sm rounded-lg border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
{loading && <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground" />}
</div>
{open && results.length > 0 && (
<div className="absolute top-full mt-1 w-full bg-popover border rounded-lg shadow-lg z-50 max-h-80 overflow-y-auto">
{results.map((r) => (
<button
key={r.id}
type="button"
className="flex items-start gap-3 w-full px-3 py-2.5 text-left hover:bg-accent transition-colors border-b last:border-0"
>
<span className="mt-0.5 text-muted-foreground">
{typeIcons[r.type] ?? <FileText className="size-4" />}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{r.title || 'Untitled'}</div>
<div className="text-xs text-muted-foreground truncate mt-0.5">
{r.plainText?.slice(0, 100)}
</div>
</div>
</button>
))}
</div>
)}
</div>
);
}