Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ next-env.d.ts

# Extension config
extension/config.js
.env
.env.local
aiarchives.log
Binary file added aiarchives.log
Binary file not shown.
86 changes: 86 additions & 0 deletions app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateEmbedding } from '@/lib/services/embeddings';
import { searchBySimilarity } from '@/lib/db/embeddings';
import { dbClient } from '@/lib/db/client';
import { s3Client } from '@/lib/storage/s3';
import { loadConfig } from '@/lib/config';

let isInitialized = false;

async function ensureInitialized() {
if (!isInitialized) {
try {
const config = loadConfig();
await dbClient.initialize(config.database);
s3Client.initialize(config.s3);
isInitialized = true;
} catch (error) {
if (error instanceof Error && error.message.includes('already initialized')) {
isInitialized = true;
} else {
throw error;
}
}
}
}

/**
* GET /api/search?q=query&limit=10
*
* Semantic search for conversations
*/
export async function GET(req: NextRequest) {
try {
await ensureInitialized();

const { searchParams } = new URL(req.url);
const query = searchParams.get('q');
const limitParam = searchParams.get('limit');

if (!query || query.trim().length === 0) {
return NextResponse.json(
{ error: 'Query parameter "q" is required' },
{ status: 400 }
);
}

const limit = limitParam ? parseInt(limitParam, 10) : 10;

// Generate embedding for search query
console.log(`Searching for: "${query}"`);
const queryEmbedding = await generateEmbedding(query);

// Search for similar conversations
const results = await searchBySimilarity(queryEmbedding, limit);

console.log(`Found ${results.length} results`);

return NextResponse.json({
query,
results,
count: results.length,
}, {
headers: {
'Access-Control-Allow-Origin': '*',
}
});

} catch (error) {
console.error('Search error:', error);
return NextResponse.json(
{ error: 'Search failed', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
6 changes: 4 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HelpCircle } from 'lucide-react';
import { Search } from 'lucide-react';
import { Card, CardContent, CardFooter } from '@/components/ui/card';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
Expand Down Expand Up @@ -96,10 +96,12 @@ const Home = async () => {
</div>
</div>
<div className='flex items-center space-x-4'>
<Link href="/search">
<Button variant='ghost' size='sm' className='flex items-center space-x-2'>
<HelpCircle className='w-5 h-5' />
<Search className='w-5 h-5' />
<span className='text-sm'>How to Use</span>
</Button>
</Link>
</div>
</header>

Expand Down
70 changes: 70 additions & 0 deletions app/search/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use client';

import { useState } from 'react';
import { SearchBar } from '@/components/SearchBar';
import { SearchResults } from '@/components/SearchResults';
import { ConversationRecord } from '@/lib/db/types';
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';

export default function SearchPage() {
const [results, setResults] = useState<ConversationRecord[]>([]);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState('');
const [hasSearched, setHasSearched] = useState(false);

const handleSearch = async (searchQuery: string) => {
setLoading(true);
setQuery(searchQuery);
setHasSearched(true);

try {
const response = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}&limit=20`);
const data = await response.json();

if (response.ok) {
setResults(data.results || []);
} else {
console.error('Search failed:', data.error);
setResults([]);
}
} catch (error) {
console.error('Search error:', error);
setResults([]);
} finally {
setLoading(false);
}
};

return (
<div className="flex flex-col min-h-screen bg-gray-100">
<header className="bg-white shadow-sm p-4">
<div className="container mx-auto max-w-6xl flex items-center">
<Link href="/" className="mr-4 text-gray-600 hover:text-gray-900">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="text-red-800 font-bold flex items-center">
<span>AI Archives - Search</span>
</div>
</div>
</header>

<main className="flex-1 container mx-auto px-4 py-8 max-w-6xl">
<div className="bg-white rounded-lg shadow-sm p-8 mb-8">
<h1 className="text-3xl font-bold mb-2 text-center">
Semantic Search
</h1>
<p className="text-center text-gray-600 mb-8">
Find conversations by meaning, not just keywords
</p>

<SearchBar onSearch={handleSearch} loading={loading} />
</div>

{hasSearched && (
<SearchResults results={results} query={query} />
)}
</main>
</div>
);
}
54 changes: 54 additions & 0 deletions components/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use client';

import { useState } from 'react';
import { Search, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';

interface SearchBarProps {
onSearch: (query: string) => void;
loading?: boolean;
}

export function SearchBar({ onSearch, loading = false }: SearchBarProps) {
const [query, setQuery] = useState('');

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
onSearch(query.trim());
}
};

return (
<form onSubmit={handleSubmit} className="w-full mb-8">
<div className="relative flex items-center">
<Search className="absolute left-4 w-5 h-5 text-gray-400" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search conversations... (e.g., 'React hooks', 'async await')"
className="w-full pl-12 pr-24 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={loading}
/>
<Button
type="submit"
disabled={loading || !query.trim()}
className="absolute right-2 bg-blue-600 hover:bg-blue-700"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Searching
</>
) : (
<>
<Search className="w-4 h-4 mr-2" />
Search
</>
)}
</Button>
</div>
</form>
);
}
81 changes: 81 additions & 0 deletions components/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use client';

import { Card, CardContent, CardFooter } from '@/components/ui/card';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import Link from 'next/link';
import { ConversationRecord } from '@/lib/db/types';

interface SearchResultsProps {
results: ConversationRecord[];
query: string;
}

export function SearchResults({ results, query }: SearchResultsProps) {
if (results.length === 0) {
return (
<Card className="p-8 text-center">
<CardContent>
<p className="text-gray-500 text-lg">
{'No conversations found for "' + query + '"'}
</p>
<p className="text-gray-400 text-sm mt-2">
Try different keywords or add more conversations
</p>
</CardContent>
</Card>
);
}

return (
<div>
<p className="text-sm text-gray-600 mb-4">
{'Found ' + results.length + ' conversation' + (results.length !== 1 ? 's' : '') + ' matching "' + query + '"'}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{results.map((conv) => {
const avatar = conv.model.charAt(0).toUpperCase();
const daysDiff = Math.floor(
(new Date().getTime() - new Date(conv.createdAt).getTime()) / (1000 * 60 * 60 * 24)
);

return (
<Link key={conv.id} href={`/conversation/${conv.id}`}>
<Card className="overflow-hidden shadow-sm hover:shadow-lg transition-all duration-200 border border-gray-200 cursor-pointer hover:border-blue-300">
<CardContent className="pt-6 px-6">
<div className="flex items-start space-x-4">
<Avatar className="h-10 w-10 bg-blue-600">
<AvatarFallback className="text-white text-sm">
{avatar}
</AvatarFallback>
</Avatar>
<div className="flex-1 pt-1">
<p className="text-sm font-medium text-gray-800">
Anonymous
</p>
<p className="text-xs text-gray-500 mt-1">
AI Conversation
</p>
</div>
</div>
</CardContent>
<CardFooter className="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-between items-center text-xs text-gray-500">
<div>
<Badge className="bg-blue-800 hover:bg-blue-700">
{conv.model}
</Badge>
</div>
<div className="flex space-x-2">
<span>{conv.views} Views</span>
<span>|</span>
<span>{daysDiff} Days ago</span>
</div>
</CardFooter>
</Card>
</Link>
);
})}
</div>
</div>
);
}
Loading