This commit is contained in:
retoor 2026-01-22 23:45:31 +01:00
commit d831d0d31c
26 changed files with 1892 additions and 0 deletions

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
EXA_API_KEY=your_exa_api_key_here
OPENROUTER_API_KEY=your_openrouter_api_key_here
OPENROUTER_MODEL=x-ai/grok-code-fast-1
PORT=8088
HOST=0.0.0.0
CACHE_TTL_HOURS=24

82
.gitignore vendored Normal file
View File

@ -0,0 +1,82 @@
Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
*.db*
C extensions
*.so
Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
PyInstaller
*.manifest
*.spec
Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
IDE
.vscode/
.idea/
*.swp
*.swo
*~
Project cache
cache.db
OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Logs
*.log
logs/
Misc
*.tmp
*.temp

40
Makefile Normal file
View File

@ -0,0 +1,40 @@
.PHONY: install run dev test clean help
help:
@echo "Rexa Search - Makefile"
@echo ""
@echo "Available targets:"
@echo " install - Install package in development mode"
@echo " run - Start server on port 8088"
@echo " dev - Start with auto-reload"
@echo " test - Run tests (if any)"
@echo " clean - Remove cache and build artifacts"
@echo " build - Build package"
@echo " help - Show this help message"
install:
pip install markdown pygments openai -e .
pip install -e .
run:
python -m rexa
dev:
uvicorn rexa.main:app.app --host 0.0.0.0 --port 8088 --reload
test:
python -m pytest tests/ -v || echo "No tests found"
clean:
rm -rf cache.db
rm -rf build/
rm -rf dist/
rm -rf *.egg-info/
find . -type d -name __pycache__ -exec rm -rf {} +
find . -type f -name "*.pyc" -delete
build:
python -m build
uninstall:
pip uninstall rexa-search -y

213
README.md Normal file
View File

@ -0,0 +1,213 @@
# Rexa Search
A classic Google-style search engine (2005-2010 era) powered by Exa API and OpenRouter. Features AI-powered search with markdown formatting, web search, and image search with thumbnail display, complete with a REST API and 24-hour result caching.
## Features
- Classic Google interface (2005-2010 era)
- **AI Search** - Perform Exa web/image search, format with OpenRouter in markdown with syntax highlighting (default option)
- Web search with Exa API
- Image search with thumbnail grid layout
- 24-hour caching for entire search pipeline (Exa results + OpenRouter formatted answers)
- Progressive Web App (PWA) support
- Fully responsive design
- REST API for programmatic access
- Installable Python package
- SQLite-based caching with dataset library
## Installation
```bash
pip install rexa-search
```
## Configuration
Set the required API keys as environment variables:
```bash
export EXA_API_KEY=your_exa_api_key_here
export OPENROUTER_API_KEY=your_openrouter_api_key_here
```
### API Keys
- **EXA_API_KEY**: For web and image search (https://dashboard.exa.ai/api-keys)
- **OPENROUTER_API_KEY**: For AI answer formatting with markdown and syntax highlighting (https://openrouter.ai/keys)
- **OPENROUTER_MODEL**: Optional, defaults to `x-ai/grok-code-fast-1`
## Usage
### Start the Server
```bash
rexa-search
```
Or using Python module:
```bash
python -m rexa
```
The server will start on port 8088 by default.
### Using Make
```bash
make install
make run
```
## API Endpoints
### Web Interface
- `GET /` - Homepage (AI search is default)
- `GET /ai?q=query` - AI search with markdown formatting and syntax highlighting
- `GET /search?q=query&num=10` - Web search results page
- `GET /images?q=query&num=20` - Image search results page
### REST API
- `GET /api/ai?q=query&num=10` - AI search with markdown formatted answer (JSON)
- Returns: `html_answer`, `markdown_answer`, `search_results`, `search_type`, `cached`
- Auto-detects web vs image search based on query keywords
- `GET /api/search?q=query&num=10` - Web search (JSON)
- `GET /api/images?q=query&num=20` - Image search (JSON)
- `GET /api/mixed?q=query&num=10` - Combined web and images (JSON)
### Response Format
AI Search:
```json
{
"html_answer": "<p>Formatted HTML with syntax highlighting</p>",
"markdown_answer": "# Answer in **markdown** format",
"search_results": [...],
"search_type": "web" | "images",
"cached": false,
"fallback": false
}
```
**Features:**
- Query type auto-detection (web vs images based on keywords)
- Markdown formatting with OpenRouter (x-ai/grok-code-fast-1)
- Syntax highlighting for code blocks
- Fallback to markdown-formatted Exa results if OpenRouter fails
- Entire pipeline cached for 24 hours
Web Search:
```json
{
"results": [
{
"title": "Page Title",
"url": "https://example.com",
"image": "https://example.com/image.jpg",
"favicon": "https://example.com/favicon.ico",
"text": "Page content snippet...",
"highlights": ["Important text..."],
"published_date": "2025-01-01",
"author": "Author Name"
}
],
"cached": false
}
```
Mixed Search:
```json
{
"web": [...],
"images": [...]
}
```
## Caching
All API requests are cached for 24 hours in a local SQLite database (`cache.db` in the working directory). This reduces API calls and improves performance.
**Cached data includes:**
- AI search results (Exa web/images search + OpenRouter formatted markdown answer)
- Web search results
- Image search results
- Mixed search results
**Cache structure:**
```python
{
"html_answer": "<p>Rendered HTML...</p>",
"markdown_answer": "# Markdown response...",
"search_results": [...],
"search_type": "web" | "images",
"fallback": false
}
```
## Development
### Setup Development Environment
```bash
git clone https://github.com/retoor/rexa-search.git
cd rexa-search
make install
```
### Run with Auto-Reload
```bash
make dev
```
### Project Structure
```
rexa/
├── rexa/
│ ├── main.py # Application entry point
│ ├── api/
│ │ ├── router.py # FastAPI routes
│ │ └── endpoints.py # Search endpoints
│ ├── core/
│ │ ├── config.py # Configuration
│ │ ├── cache.py # 24h caching logic
│ │ └── exa_client.py # Exa API wrapper
│ ├── templates/
│ │ ├── base.html
│ │ ├── home.html
│ │ ├── results_web.html
│ │ └── results_images.html
│ └── static/
│ ├── css/
│ │ └── style.css
│ └── js/
├── setup.py
├── pyproject.toml
├── Makefile
└── README.md
```
## Configuration Options
Environment variables:
- `EXA_API_KEY` - Your Exa API key (required)
- `PORT` - Server port (default: 8088)
- `HOST` - Server host (default: 0.0.0.0)
- `CACHE_TTL_HOURS` - Cache time-to-live in hours (default: 24)
## License
MIT License - See LICENSE file for details.
## Author
retoor <retoor@molodetz.nl>
## Acknowledgments
- Powered by Exa API - https://exa.ai
- Classic Google design inspiration

50
pyproject.toml Normal file
View File

@ -0,0 +1,50 @@
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "rexa-search"
version = "1.0.0"
description = "Classic Google-style search engine powered by Exa API"
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
authors = [
{name = "retoor", email = "retoor@molodetz.nl"}
]
keywords = ["search", "exa", "api", "google", "classic"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"fastapi>=0.104.0",
"uvicorn[standard]>=0.24.0",
"jinja2>=3.1.2",
"exa-py>=1.2.0",
"python-dotenv>=1.0.0",
"dataset>=1.6.0",
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
"markdown>=3.5.0",
"pygments>=2.16.0",
"openai>=1.12.0",
]
[project.urls]
Homepage = "https://github.com/retoor/rexa-search"
Documentation = "https://github.com/retoor/rexa-search/blob/main/README.md"
Repository = "https://github.com/retoor/rexa-search"
Issues = "https://github.com/retoor/rexa-search/issues"
[project.scripts]
rexa-search = "rexa.main:app.run"
[tool.setuptools]
packages = ["rexa"]
[tool.setuptools.package-data]
rexa = ["templates/*.html", "static/css/*.css", "static/icons/*"]

6
rexa/__init__.py Normal file
View File

@ -0,0 +1,6 @@
"""
Rexa Search - Classic Google-style search engine powered by Exa API
Author: retoor <retoor@molodetz.nl>
"""
__version__ = "1.0.0"

4
rexa/__main__.py Normal file
View File

@ -0,0 +1,4 @@
from rexa.main import app
if __name__ == "__main__":
app.run()

5
rexa/api/__init__.py Normal file
View File

@ -0,0 +1,5 @@
from fastapi import APIRouter
from .endpoints import router
__all__ = ["router"]

223
rexa/api/endpoints.py Normal file
View File

@ -0,0 +1,223 @@
from typing import Dict, List, Any
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi.responses import HTMLResponse, JSONResponse
from ..core import Cache, ExaClient, OpenRouterClient, MarkdownRenderer, settings
def _format_results_as_markdown(query: str, results: List[Dict]) -> str:
if not results:
return f"No search results found for: {query}"
output = f"# Search Results for: {query}\n\n"
for i, result in enumerate(results, 1):
output += f"## {i}. {result.get('title', 'Untitled')}\n\n"
output += f"**URL:** {result.get('url', '')}\n\n"
text = result.get('text', '')
if text:
output += f"{text[:500]}...\n\n"
highlights = result.get('highlights', [])
if highlights:
output += f"**Key points:**\n"
for highlight in highlights:
output += f"- {highlight}\n"
output += "\n"
return output
router = APIRouter()
cache = Cache(settings.CACHE_DB_PATH)
exa = ExaClient()
openrouter = OpenRouterClient()
renderer = MarkdownRenderer()
@router.get("/", response_class=HTMLResponse)
async def home(request: Request):
return request.state.templates.TemplateResponse("home.html", {"request": request})
@router.get("/ai", response_class=HTMLResponse)
async def search_ai(request: Request, q: str = Query(...), num: int = 10):
cached = cache.get(q, "ai", num)
if cached:
markdown_answer = cached.get("markdown_answer", "")
html_answer = renderer.render(markdown_answer)
search_results = cached.get("search_results", [])
search_type = cached.get("search_type", "web")
else:
try:
search_data = exa.answer_ai(q, num)
search_results = search_data.get("results", [])
search_type = search_data.get("search_type", "web")
markdown_answer = openrouter.format_results_as_markdown(q, search_results)
html_answer = renderer.render(markdown_answer)
cache.set(q, "ai", num, {
"html_answer": html_answer,
"search_results": search_results,
"search_type": search_type,
"markdown_answer": markdown_answer
})
except Exception as e:
fallback_results = exa.search_web(q, num)
markdown_answer = _format_results_as_markdown(q, fallback_results)
html_answer = renderer.render(markdown_answer)
cache.set(q, "ai", num, {
"html_answer": html_answer,
"search_results": fallback_results,
"search_type": "web",
"markdown_answer": markdown_answer,
"fallback": True
})
search_results = fallback_results
search_type = "web"
return request.state.templates.TemplateResponse(
"results_ai.html",
{
"request": request,
"query": q,
"answer": html_answer,
"search_results": search_results,
"search_type": search_type
}
)
@router.get("/api/ai")
async def api_ai(q: str = Query(...), num: int = Query(10)):
if not q.strip():
raise HTTPException(status_code=400, detail="Query parameter 'q' is required")
cached = cache.get(q, "ai", num)
if cached:
markdown_answer = cached.get("markdown_answer", "")
html_answer = renderer.render(markdown_answer)
search_results = cached.get("search_results", [])
search_type = cached.get("search_type", "web")
return {
"html_answer": html_answer,
"markdown_answer": markdown_answer,
"search_results": search_results,
"search_type": search_type,
"cached": True
}
try:
search_data = exa.answer_ai(q, num)
search_results = search_data.get("results", [])
search_type = search_data.get("search_type", "web")
markdown_answer = openrouter.format_results_as_markdown(q, search_results)
html_answer = renderer.render(markdown_answer)
result = {
"html_answer": html_answer,
"markdown_answer": markdown_answer,
"search_results": search_results,
"search_type": search_type,
"cached": False
}
cache.set(q, "ai", num, result)
return result
except Exception as e:
fallback_results = exa.search_web(q, num)
markdown_answer = _format_results_as_markdown(q, fallback_results)
html_answer = renderer.render(markdown_answer)
result = {
"html_answer": html_answer,
"markdown_answer": markdown_answer,
"search_results": fallback_results,
"search_type": "web",
"cached": False,
"error": str(e),
"fallback": True
}
cache.set(q, "ai", num, result)
return result
@router.get("/search", response_class=HTMLResponse)
async def search_web(request: Request, q: str = Query(...), num: int = 10):
cached_results = cache.get(q, "web", num)
if cached_results:
results = cached_results.get("results", [])
else:
results = exa.search_web(q, num)
cache.set(q, "web", num, {"results": results})
return request.state.templates.TemplateResponse(
"results_web.html",
{"request": request, "query": q, "results": results},
)
@router.get("/images", response_class=HTMLResponse)
async def search_images(request: Request, q: str = Query(...), num: int = 20):
cached_results = cache.get(q, "images", num)
if cached_results:
results = cached_results.get("results", [])
else:
results = exa.search_images(q, num)
cache.set(q, "images", num, {"results": results})
return request.state.templates.TemplateResponse(
"results_images.html",
{"request": request, "query": q, "results": results},
)
@router.get("/api/search")
async def api_search(q: str = Query(...), num: int = Query(10)):
if not q.strip():
raise HTTPException(status_code=400, detail="Query parameter 'q' is required")
cached_results = cache.get(q, "web", num)
if cached_results:
return {"results": cached_results.get("results", []), "cached": True}
results = exa.search_web(q, num)
cache.set(q, "web", num, {"results": results})
return {"results": results, "cached": False}
@router.get("/api/images")
async def api_images(q: str = Query(...), num: int = Query(20)):
if not q.strip():
raise HTTPException(status_code=400, detail="Query parameter 'q' is required")
cached_results = cache.get(q, "images", num)
if cached_results:
return {"results": cached_results.get("results", []), "cached": True}
results = exa.search_images(q, num)
cache.set(q, "images", num, {"results": results})
return {"results": results, "cached": False}
@router.get("/api/mixed")
async def api_mixed(q: str = Query(...), num: int = Query(10)):
if not q.strip():
raise HTTPException(status_code=400, detail="Query parameter 'q' is required")
cached_results = cache.get(q, "mixed", num)
if cached_results:
return cached_results
results = exa.search_mixed(q, num)
cache.set(q, "mixed", num, {"results": results})
return results

7
rexa/api/router.py Normal file
View File

@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
from .endpoints import router as endpoints_router
router.include_router(endpoints_router, tags=["search"])

7
rexa/core/__init__.py Normal file
View File

@ -0,0 +1,7 @@
from .config import settings
from .cache import Cache
from .exa_client import ExaClient
from .openrouter_client import OpenRouterClient
from .markdown_renderer import MarkdownRenderer
__all__ = ["settings", "Cache", "ExaClient", "OpenRouterClient", "MarkdownRenderer"]

45
rexa/core/cache.py Normal file
View File

@ -0,0 +1,45 @@
import hashlib
import json
from datetime import datetime, timedelta
from typing import Optional
import dataset
class Cache:
def __init__(self, db_path: str):
self.db = dataset.connect(f"sqlite:///{db_path}")
self.table = self.db["cache"]
def _generate_key(self, query: str, search_type: str, num_results: int) -> str:
key_string = f"{query}:{search_type}:{num_results}"
return hashlib.md5(key_string.encode()).hexdigest()
def get(self, query: str, search_type: str, num_results: int) -> Optional[dict]:
cache_key = self._generate_key(query, search_type, num_results)
cached = self.table.find_one(cache_key=cache_key)
if cached:
cached_at = datetime.fromisoformat(cached["cached_at"])
if datetime.now() - cached_at < timedelta(hours=24):
return json.loads(cached["results_json"])
else:
self.table.delete(cache_key=cache_key)
return None
def set(self, query: str, search_type: str, num_results: int, results: dict) -> None:
cache_key = self._generate_key(query, search_type, num_results)
self.table.upsert(
{
"cache_key": cache_key,
"search_type": search_type,
"query_text": query,
"results_json": json.dumps(results),
"cached_at": datetime.now().isoformat(),
},
["cache_key"],
)
def clear(self) -> None:
self.table.delete()

20
rexa/core/config.py Normal file
View File

@ -0,0 +1,20 @@
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
EXA_API_KEY: str = os.getenv("EXA_API_KEY", "")
OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "x-ai/grok-code-fast-1")
PORT: int = 8088
HOST: str = "0.0.0.0"
CACHE_TTL_HOURS: int = 24
NUM_RESULTS_DEFAULT: int = 10
NUM_IMAGES_DEFAULT: int = 20
CACHE_DB_PATH: str = "cache.db"
class Config:
env_file = ".env"
settings = Settings()

84
rexa/core/exa_client.py Normal file
View File

@ -0,0 +1,84 @@
from typing import List, Dict
from exa_py import Exa
from .config import settings
class ExaClient:
def __init__(self):
self.client = Exa(settings.EXA_API_KEY)
def search_web(self, query: str, num_results: int = 10) -> List[Dict]:
response = self.client.search_and_contents(
query,
num_results=num_results,
text=True,
highlights=True,
)
return self._format_results(response.results)
def search_images(self, query: str, num_results: int = 20) -> List[Dict]:
response = self.client.search_and_contents(
query,
num_results=num_results,
text=True,
)
return [r for r in self._format_results(response.results) if r.get("image")]
def search_mixed(self, query: str, num_results: int = 10) -> Dict[str, List[Dict]]:
response = self.client.search_and_contents(
query,
num_results=num_results * 2,
text=True,
)
results = self._format_results(response.results)
web_results = results[:num_results]
image_results = [r for r in results if r.get("image")][:num_results]
return {"web": web_results, "images": image_results}
def answer_ai(self, query: str, num_results: int = 10) -> Dict:
image_keywords = ["image", "photo", "picture", "pic", "img", "screenshot", "drawing", "art", "graphic"]
query_lower = query.lower()
for keyword in image_keywords:
if keyword in query_lower:
results = self.search_images(query, num_results)
return {
"results": results,
"search_type": "images"
}
results = self.search_web(query, num_results)
return {
"results": results,
"search_type": "web"
}
def _format_results(self, results: List) -> List[Dict]:
formatted = []
for result in results:
title = getattr(result, "title", "") or ""
url = getattr(result, "url", "") or ""
image = getattr(result, "image", "") or ""
favicon = getattr(result, "favicon", "") or ""
text = getattr(result, "text", "") or ""
highlights = getattr(result, "highlights", []) or []
published_date = getattr(result, "publishedDate", "") or ""
author = getattr(result, "author", "") or ""
formatted.append(
{
"title": title,
"url": url,
"image": image,
"favicon": favicon,
"text": text[:200] if text else "",
"highlights": list(highlights) if highlights else [],
"published_date": str(published_date) if published_date else "",
"author": str(author) if author else "",
}
)
return formatted

View File

@ -0,0 +1,32 @@
import markdown
from pygments import highlight
from pygments.lexers import guess_lexer
from pygments.formatters import HtmlFormatter
class MarkdownRenderer:
def __init__(self):
self.formatter = HtmlFormatter(
style='default',
noclasses=False,
cssclass='highlight'
)
self.md = markdown.Markdown(
extensions=[
'codehilite',
'fenced_code',
'tables',
'nl2br',
'sane_lists'
],
extension_configs={
'codehilite': {
'css_class': 'highlight',
'linenos': False,
'guess_lang': False
}
}
)
def render(self, markdown_text: str) -> str:
return self.md.convert(markdown_text)

View File

@ -0,0 +1,69 @@
from typing import List, Dict, Any
import json
from openai import OpenAI
from .config import settings
class OpenRouterClient:
def __init__(self):
self.client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=settings.OPENROUTER_API_KEY
)
def _determine_search_type(self, query: str) -> str:
image_keywords = ["image", "photo", "picture", "pic", "img", "screenshot", "drawing", "art", "graphic"]
query_lower = query.lower()
for keyword in image_keywords:
if keyword in query_lower:
return "images"
return "web"
def format_results_as_markdown(self, query: str, results: List[Dict]) -> str:
search_type = self._determine_search_type(query)
system_prompt = """You are a helpful AI assistant. Your task is to synthesize and summarize information from search results.
Guidelines:
1. Provide a clear, comprehensive answer to the user's question
2. Use information from search results
3. Structure your response with headings for better readability
4. Use markdown formatting appropriately:
- Use **bold** for emphasis
- Use `inline code` for technical terms
- Use ```language code blocks``` for multi-line code examples
- Use bullet points or numbered lists for multiple items
5. If results contain code snippets, include them in properly formatted code blocks with appropriate language tags
6. Be concise but complete
7. If there's conflicting information, note it
8. If information is insufficient, acknowledge limitations
9. Maintain consistent structure across responses
10. Use H2 for main sections, H3 for subsections
Format your response as clean, professional markdown."""
messages = [
{
"role": "system",
"content": system_prompt
},
{
"role": "user",
"content": f"Question: {query}\n\nSearch Results ({search_type}):\n{json.dumps(results, indent=2)}"
}
]
try:
response = self.client.chat.completions.create(
model=settings.OPENROUTER_MODEL,
messages=messages,
temperature=0.1,
max_tokens=4096
)
content = response.choices[0].message.content
return content if content else ""
except Exception as e:
raise Exception(f"OpenRouter API error: {e}")

95
rexa/main.py Normal file
View File

@ -0,0 +1,95 @@
import uvicorn
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from .api.router import router
from .core import settings
class Application:
def __init__(self):
self.app = FastAPI(title="Rexa Search", version="1.0.0")
self._setup_middleware()
self._setup_static_files()
self._setup_templates()
self._setup_routes()
def _setup_middleware(self):
@self.app.middleware("http")
async def add_templates_to_request(request: Request, call_next):
request.state.templates = self.templates
response = await call_next(request)
return response
def _setup_static_files(self):
self.app.mount("/static", StaticFiles(directory="rexa/static"), name="static")
def _setup_templates(self):
self.templates = Jinja2Templates(directory="rexa/templates")
def _setup_routes(self):
self.app.include_router(router)
@self.app.get("/manifest.json")
async def manifest():
from fastapi.responses import JSONResponse
return JSONResponse(
{
"name": "Rexa Search",
"short_name": "Rexa",
"description": "Classic Google-style search engine powered by Exa API",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
)
@self.app.get("/sw.js")
async def service_worker():
from fastapi.responses import Response
sw_content = """const CACHE_NAME = 'rexa-cache-v1';
const urlsToCache = [
'/',
'/static/css/style.css'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
});
"""
return Response(content=sw_content, media_type="application/javascript")
def run(self):
uvicorn.run(
self.app,
host=settings.HOST,
port=settings.PORT,
log_level="info"
)
app = Application()

627
rexa/static/css/style.css Normal file
View File

@ -0,0 +1,627 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
font-size: 13px;
line-height: 1.4;
color: #000;
background-color: #fff;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.container {
flex: 1;
}
footer {
background: #f5f5f5;
border-top: 1px solid #ddd;
padding: 15px 20px;
text-align: center;
color: #666;
font-size: 11px;
margin-top: auto;
}
footer p {
margin: 0;
}
a {
color: #0000cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:visited {
color: #551a8b;
}
.home-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 80px);
padding: 20px;
}
.logo {
margin-bottom: 30px;
}
.logo h1 {
font-size: 60px;
font-weight: normal;
color: #000;
letter-spacing: -2px;
}
.search-form {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 600px;
}
.search-box {
width: 100%;
margin-bottom: 20px;
}
.search-box input {
width: 100%;
padding: 10px 15px;
font-size: 16px;
border: 1px solid #d9d9d9;
border-radius: 2px;
outline: none;
}
.search-box input:focus {
border-color: #4d90fe;
box-shadow: 0 0 3px rgba(77, 144, 254, 0.3);
}
.search-buttons {
display: flex;
gap: 10px;
}
.search-buttons button {
padding: 10px 20px;
font-size: 13px;
background: #f5f5f5;
border: 1px solid #dcdcdc;
border-radius: 2px;
cursor: pointer;
color: #333;
font-weight: bold;
}
.search-buttons button:hover {
border-color: #c6c6c6;
background: #f8f8f8;
}
.search-tips {
margin-top: 30px;
color: #666;
font-size: 12px;
}
.results-header {
background: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
padding: 20px 20px 10px 20px;
}
.search-form-inline {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.search-logo a {
text-decoration: none;
}
.search-logo h1 {
font-size: 28px;
font-weight: normal;
color: #000;
letter-spacing: -1px;
}
.search-box-inline {
display: flex;
flex: 1;
max-width: 600px;
}
.search-box-inline input {
flex: 1;
padding: 8px 12px;
font-size: 14px;
border: 1px solid #d9d9d9;
border-radius: 2px 0 0 2px;
outline: none;
}
.search-box-inline input:focus {
border-color: #4d90fe;
}
.search-box-inline button {
padding: 8px 16px;
font-size: 13px;
background: #4d90fe;
border: 1px solid #3079ed;
border-radius: 0 2px 2px 0;
cursor: pointer;
color: #fff;
font-weight: bold;
}
.search-box-inline button:hover {
background: #357ae8;
border-color: #2f5bb7;
}
.search-tabs {
margin-top: 10px;
display: flex;
gap: 5px;
}
.search-tabs a {
padding: 8px 16px;
background: #f5f5f5;
border: 1px solid #dcdcdc;
border-radius: 2px;
color: #333;
font-size: 13px;
text-decoration: none;
}
.search-tabs a.active {
background: #fff;
border-bottom: 2px solid #4d90fe;
}
.search-tabs a:hover {
background: #f8f8f8;
}
.results-stats {
padding: 8px 20px;
color: #666;
font-size: 12px;
}
.results-container {
max-width: 800px;
padding: 10px 20px 30px 20px;
}
.result-item {
margin: 0 0 20px 0;
}
.result-item h3 {
margin: 0 0 2px 0;
font-size: 16px;
font-weight: normal;
}
.result-item h3 a {
color: #0000cc;
text-decoration: none;
}
.result-item h3 a:hover {
text-decoration: underline;
}
.result-url {
color: #008000;
font-size: 12px;
margin: 0 0 4px 0;
}
.result-snippet {
color: #333;
font-size: 13px;
line-height: 1.5;
}
.no-results {
text-align: center;
padding: 50px 20px;
color: #666;
}
.no-results p {
margin: 10px 0;
}
.ai-answer-container {
padding: 10px 20px 30px 20px;
margin-bottom: 30px;
}
.ai-answer {
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 2px;
padding: 20px;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.ai-citations {
max-width: 800px;
padding: 10px 20px 30px 20px;
}
.ai-citations h3 {
font-size: 14px;
font-weight: bold;
margin: 0 0 15px 0;
color: #333;
}
.ai-citations-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.ai-citation-item {
padding: 15px;
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 2px;
}
.ai-citation-item h4 {
margin: 0 0 5px 0;
font-size: 13px;
font-weight: normal;
}
.ai-citation-item h4 a {
color: #0000cc;
text-decoration: none;
}
.ai-citation-item h4 a:hover {
text-decoration: underline;
}
.citation-url {
color: #008000;
font-size: 11px;
margin: 0 0 8px 0;
word-break: break-all;
}
.citation-text {
font-size: 12px;
color: #666;
line-height: 1.4;
}
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
padding: 10px 20px 30px 20px;
max-width: 1200px;
}
.image-item {
border: 1px solid #e0e0e0;
border-radius: 3px;
overflow: hidden;
background: #fff;
transition: box-shadow 0.2s;
}
.image-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.image-item a {
display: block;
text-decoration: none;
}
.image-thumbnail {
width: 100%;
height: 150px;
overflow: hidden;
background: #f5f5f5;
}
.image-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-info {
padding: 8px;
}
.image-title {
font-size: 12px;
color: #333;
margin: 0 0 4px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-url {
font-size: 10px;
color: #008000;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 768px) {
.logo h1 {
font-size: 40px;
}
.search-form-inline {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.search-box-inline {
width: 100%;
}
.search-logo {
width: 100%;
}
.search-logo h1 {
font-size: 24px;
}
.images-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.image-thumbnail {
height: 120px;
}
}
@media (max-width: 480px) {
.logo h1 {
font-size: 32px;
}
.search-buttons {
flex-direction: column;
width: 100%;
}
.search-buttons button {
width: 100%;
}
.images-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
padding: 10px 10px 30px 10px;
}
.image-thumbnail {
height: 100px;
}
.ai-answer-container {
padding: 10px 15px 20px 15px;
}
.ai-answer {
padding: 15px;
font-size: 13px;
}
.ai-citations {
padding: 10px 15px 20px 15px;
}
.ai-citation-item {
padding: 12px;
}
}
.ai-answer {
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 2px;
padding: 20px;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.ai-answer h1 {
font-size: 24px;
font-weight: bold;
margin-top: 0;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #e0e0e0;
}
.ai-answer h2 {
font-size: 20px;
font-weight: bold;
margin-top: 25px;
margin-bottom: 12px;
}
.ai-answer h3 {
font-size: 18px;
font-weight: bold;
margin-top: 20px;
margin-bottom: 10px;
}
.ai-answer h4 {
font-size: 16px;
font-weight: bold;
margin-top: 18px;
margin-bottom: 8px;
}
.ai-answer p {
margin: 10px 0;
}
.ai-answer ul, .ai-answer ol {
margin: 10px 0;
padding-left: 25px;
}
.ai-answer li {
margin: 5px 0;
line-height: 1.5;
}
.ai-answer strong {
font-weight: bold;
}
.ai-answer code {
background: #f4f4f4;
border: 1px solid #ddd;
border-radius: 3px;
padding: 2px 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: #c7254e;
}
.ai-answer pre {
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 3px;
padding: 15px;
margin: 15px 0;
overflow-x: auto;
}
.highlight {
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 3px;
padding: 15px;
margin: 15px 0;
overflow-x: auto;
}
.highlight pre {
margin: 0;
padding: 0;
font-size: 13px;
line-height: 1.4;
}
.highlight code {
font-family: 'Courier New', monospace;
}
.highlight .tok {
display: inline;
}
.highlight .tok-comment {
color: #999;
font-style: italic;
}
.highlight .tok-keyword {
color: #008000;
font-weight: bold;
}
.highlight .tok-string {
color: #a31515;
}
.highlight .tok-number {
color: #099;
}
.highlight .tok-function {
color: #0000ff;
}
.search-results-section {
max-width: 800px;
padding: 20px 20px 30px 20px;
}
.search-results-section h3 {
font-size: 14px;
font-weight: bold;
margin: 0 0 15px 0;
color: #333;
}
@media (max-width: 768px) {
.ai-answer-container {
padding: 10px 15px 20px 15px;
}
.ai-answer {
padding: 15px;
font-size: 13px;
}
.ai-answer h1 {
font-size: 20px;
}
.ai-answer h2 {
font-size: 18px;
}
.ai-answer h3 {
font-size: 16px;
}
.highlight {
padding: 10px;
margin: 10px 0;
}
.search-results-section {
padding: 15px 15px 20px 15px;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

31
rexa/templates/base.html Normal file
View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Rexa Search{% endblock %}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#ffffff">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
{% block head %}{% endblock %}
</head>
<body>
<div class="container">
{% block content %}{% endblock %}
</div>
<footer>
<p>&copy; 2025 Rexa Search. Powered by Exa API.</p>
</footer>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

32
rexa/templates/home.html Normal file
View File

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Rexa Search{% endblock %}
{% block content %}
<div class="home-container">
<div class="logo">
<h1>Rexa Search</h1>
</div>
<form action="/ai" method="get" class="search-form">
<div class="search-box">
<input
type="text"
name="q"
placeholder="Ask anything..."
autocomplete="off"
autofocus
>
</div>
<div class="search-buttons">
<button type="submit" formaction="/ai">AI Search</button>
<button type="submit" formaction="/search">Web Search</button>
<button type="submit" formaction="/images">Image Search</button>
</div>
</form>
<div class="search-tips">
<p>Powered by Exa API</p>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}{{ query }} - AI Search - Rexa Search{% endblock %}
{% block content %}
<div class="results-header">
<form action="/ai" method="get" class="search-form-inline">
<div class="search-logo">
<a href="/"><h1>Rexa Search</h1></a>
</div>
<div class="search-box-inline">
<input
type="text"
name="q"
value="{{ query }}"
autocomplete="off"
>
<button type="submit">Ask AI</button>
</div>
</form>
<div class="search-tabs">
<a href="/ai?q={{ query }}" class="active">AI</a>
<a href="/search?q={{ query }}">Web</a>
<a href="/images?q={{ query }}">Images</a>
</div>
</div>
<div class="ai-answer-container">
<div class="ai-answer">
{{ answer|safe }}
</div>
</div>
{% if search_results %}
<div class="search-results-section">
<h3>Search Results ({{ search_type }})</h3>
<div class="results-container">
{% for result in search_results %}
<div class="result-item">
<h4>
<a href="{{ result.url }}" target="_blank" rel="noopener noreferrer">
{{ result.title }}
</a>
</h4>
<div class="result-url">{{ result.url }}</div>
<div class="result-snippet">
{% if result.highlights %}
{{ result.highlights[0]|safe }}
{% else %}
{{ result.text }}
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if not answer %}
<div class="no-results">
<p>Unable to generate answer for "{{ query }}"</p>
<p>Please try again or use Web search instead.</p>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}{{ query }} - Images - Rexa Search{% endblock %}
{% block content %}
<div class="results-header">
<form action="/images" method="get" class="search-form-inline">
<div class="search-logo">
<a href="/"><h1>Rexa Search</h1></a>
</div>
<div class="search-box-inline">
<input
type="text"
name="q"
value="{{ query }}"
autocomplete="off"
>
<button type="submit">Search Images</button>
</div>
</form>
<div class="search-tabs">
<a href="/ai?q={{ query }}">AI</a>
<a href="/search?q={{ query }}">Web</a>
<a href="/images?q={{ query }}" class="active">Images</a>
</div>
</div>
<div class="results-stats">
<p>About {{ results|length }} images found</p>
</div>
<div class="images-grid">
{% for result in results %}
<div class="image-item">
<a href="{{ result.url }}" target="_blank" rel="noopener noreferrer">
<div class="image-thumbnail">
<img src="{{ result.image }}" alt="{{ result.title }}" loading="lazy">
</div>
<div class="image-info">
<p class="image-title">{{ result.title }}</p>
<p class="image-url">{{ result.url[:50] }}...</p>
</div>
</a>
</div>
{% endfor %}
{% if not results %}
<div class="no-results">
<p>No images found for "{{ query }}"</p>
<p>Try different keywords or search the web instead.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}{{ query }} - Rexa Search{% endblock %}
{% block content %}
<div class="results-header">
<form action="/search" method="get" class="search-form-inline">
<div class="search-logo">
<a href="/"><h1>Rexa Search</h1></a>
</div>
<div class="search-box-inline">
<input
type="text"
name="q"
value="{{ query }}"
autocomplete="off"
>
<button type="submit">Search</button>
</div>
</form>
<div class="search-tabs">
<a href="/ai?q={{ query }}">AI</a>
<a href="/search?q={{ query }}" class="active">Web</a>
<a href="/images?q={{ query }}">Images</a>
</div>
</div>
<div class="results-stats">
<p>About {{ results|length }} results found</p>
</div>
<div class="results-container">
{% for result in results %}
<div class="result-item">
<h3>
<a href="{{ result.url }}" target="_blank" rel="noopener noreferrer">{{ result.title }}</a>
</h3>
<div class="result-url">{{ result.url }}</div>
<div class="result-snippet">
{% if result.highlights %}
{{ result.highlights[0]|safe }}
{% else %}
{{ result.text }}
{% endif %}
</div>
</div>
{% endfor %}
{% if not results %}
<div class="no-results">
<p>No results found for "{{ query }}"</p>
<p>Try different keywords or check your spelling.</p>
</div>
{% endif %}
</div>
{% endblock %}

36
setup.py Normal file
View File

@ -0,0 +1,36 @@
from setuptools import setup, find_packages
setup(
name="rexa-search",
version="1.0.0",
packages=find_packages(),
install_requires=[
"fastapi>=0.104.0",
"uvicorn[standard]>=0.24.0",
"jinja2>=3.1.2",
"exa-py>=1.2.0",
"python-dotenv>=1.0.0",
"dataset>=1.6.0",
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
],
python_requires=">=3.11",
entry_points={
"console_scripts": [
"rexa-search=rexa.main:app.run",
],
},
author="retoor",
author_email="retoor@molodetz.nl",
description="Classic Google-style search engine powered by Exa API",
long_description=open("README.md").read(),
long_description_content_type="text/markdown",
url="https://github.com/retoor/rexa-search",
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
)