UPdate.
This commit is contained in:
commit
d831d0d31c
6
.env.example
Normal file
6
.env.example
Normal 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
82
.gitignore
vendored
Normal 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
40
Makefile
Normal 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
213
README.md
Normal 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
50
pyproject.toml
Normal 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
6
rexa/__init__.py
Normal 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
4
rexa/__main__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from rexa.main import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
5
rexa/api/__init__.py
Normal file
5
rexa/api/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .endpoints import router
|
||||
|
||||
__all__ = ["router"]
|
||||
223
rexa/api/endpoints.py
Normal file
223
rexa/api/endpoints.py
Normal 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
7
rexa/api/router.py
Normal 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
7
rexa/core/__init__.py
Normal 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
45
rexa/core/cache.py
Normal 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
20
rexa/core/config.py
Normal 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
84
rexa/core/exa_client.py
Normal 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
|
||||
32
rexa/core/markdown_renderer.py
Normal file
32
rexa/core/markdown_renderer.py
Normal 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)
|
||||
69
rexa/core/openrouter_client.py
Normal file
69
rexa/core/openrouter_client.py
Normal 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
95
rexa/main.py
Normal 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
627
rexa/static/css/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
rexa/static/icons/icon-192.png
Normal file
BIN
rexa/static/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
rexa/static/icons/icon-512.png
Normal file
BIN
rexa/static/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
31
rexa/templates/base.html
Normal file
31
rexa/templates/base.html
Normal 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>© 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
32
rexa/templates/home.html
Normal 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 %}
|
||||
66
rexa/templates/results_ai.html
Normal file
66
rexa/templates/results_ai.html
Normal 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 %}
|
||||
55
rexa/templates/results_images.html
Normal file
55
rexa/templates/results_images.html
Normal 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 %}
|
||||
57
rexa/templates/results_web.html
Normal file
57
rexa/templates/results_web.html
Normal 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
36
setup.py
Normal 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",
|
||||
],
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user