mirror of
https://github.com/wahyd4/hey-search.git
synced 2026-08-09 05:06:23 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12904fa03a | ||
|
|
d5d021299c | ||
|
|
bdffeafa17 | ||
|
|
c5c18ae74a | ||
|
|
90ec14ef23 | ||
|
|
4e5b0424b0 | ||
|
|
e486ca1dc7 |
+11
@@ -86,12 +86,23 @@
|
||||
|
||||
- Full REST API for search, autocomplete, engine management, bookmarks, history, stats, and settings
|
||||
- `/api/search` accepts both **GET and POST** requests with query string parameters
|
||||
- **`format=llm`** — minimal LLM-optimised response: only `query`, `results` (title, url, snippet, date), and `total_results`; no engine noise
|
||||
- **`max_results`** — hard limit on returned results (1–100); `numResults` is a supported alias
|
||||
- Compatibility parameters: `pageNumber` (alias for `page`), `numResults`, `format`, `imageProxy`, `safesearch`
|
||||
- Every result includes `result_id`, `rank`, `engine`, and `published_date`
|
||||
- `has_next` and `total_results` fields for cursor-aware pagination
|
||||
- `X-Response-Time-Ms` response header on all endpoints
|
||||
- OpenAPI specification with interactive docs via [Swagger UI](https://swagger.io/tools/swagger-ui/) (`/docs`) and [Redoc](https://github.com/Redocly/redoc) (`/redoc`)
|
||||
|
||||
## MCP Tool Server
|
||||
|
||||
- **`/api/mcp`** — [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) endpoint; exposes HeySearch as a native tool for LLMs
|
||||
- Compatible with Claude Desktop, Cursor, Continue, VS Code Copilot, and any MCP-capable client
|
||||
- **Transport**: Streamable HTTP (JSON-RPC 2.0 POST)
|
||||
- **Available tools**: `search` (web + image search) and `autocomplete`
|
||||
- `search` tool supports `query`, `category`, `num_results` (1–20), `engines`, `sort`, and `date_filter` arguments
|
||||
- Add to Claude Desktop by pointing `url` at `http://your-host/api/mcp` with `"transport": "http"`
|
||||
|
||||
## Reliability
|
||||
|
||||
- **Retry mechanism** — failed upstream requests are retried with exponential backoff (via [tenacity](https://github.com/jd/tenacity), 2 attempts)
|
||||
|
||||
@@ -18,9 +18,9 @@ Both are open-source, self-hosted, privacy-respecting metasearch engines. Here's
|
||||
|
||||
| | HeySearch | SearXNG |
|
||||
|---|---|---|
|
||||
| **Setup** | `docker run -p 8000:8000 ghcr.io/…/hey-search` — one command, zero config | Requires YAML config, engine tuning, often breaks out of the box |
|
||||
| **UI** | Modern, clean React UI with dark mode, background images, image lightbox | Functional but dated — not optimised for mobile or daily use |
|
||||
| **AI agent friendly** | Clean JSON REST API, OpenAPI docs at `/docs`, designed to be queried programmatically | API exists but less documented; HTML-heavy responses |
|
||||
| **Setup** | `docker compose up -d` — one command, zero config | Requires YAML config, engine tuning, sometimes breaks |
|
||||
| **UI** | Modern, clean React UI with dark mode, background images, image lightbox | Functional but dated — not mobile friendly |
|
||||
| **AI agent friendly** | MCP tool server at `/api/mcp` (Claude Desktop, Cursor, Continue), `format=llm` for minimal responses, clean JSON REST API, OpenAPI docs at `/docs` | API exists but less documented; HTML-heavy responses |
|
||||
| **Bookmarks** | Built-in bookmark manager for results | ❌ |
|
||||
| **Search history** | Full search history with timestamps, re-run any past query in one click | ❌ |
|
||||
| **Usage stats** | Built-in analytics dashboard — top queries, click-through rates, engine usage | ❌ |
|
||||
@@ -90,6 +90,7 @@ The `/app/data` volume stores the SQLite database (engine settings, excluded dom
|
||||
| GET | `/api/settings` | Get app settings (cache TTL)|
|
||||
| PUT | `/api/settings` | Update settings |
|
||||
| DELETE | `/api/cache` | Flush search cache |
|
||||
| POST | `/api/mcp` | MCP tool server (for LLMs) |
|
||||
|
||||
### Search endpoint parameters
|
||||
|
||||
@@ -99,37 +100,84 @@ The `/app/data` volume stores the SQLite database (engine settings, excluded dom
|
||||
| `category` | `web` | `web` or `images` |
|
||||
| `page` | `1` | Page number (1–50) |
|
||||
| `pageNumber` | — | Alias for `page` (takes precedence when provided) |
|
||||
| `numResults` | — | Requested result count hint (informational) |
|
||||
| `format` | — | Response format hint (e.g. `json`) |
|
||||
| `max_results`| — | Hard limit on results returned (1–100) |
|
||||
| `numResults` | — | Alias for `max_results` |
|
||||
| `format` | — | `llm` for minimal LLM-friendly response (see below) |
|
||||
| `imageProxy` | — | Client image-proxy preference flag (informational) |
|
||||
| `safesearch` | — | Safe search level: `0` off, `1` moderate, `2` strict |
|
||||
| `engines` | — | Comma-separated engine names to restrict (e.g. `google,bing`) |
|
||||
| `image_size` | — | `large`, `medium`, or `small` (images only) |
|
||||
| `sort` | `default`| `default`, `date_asc`, or `date_desc` |
|
||||
| `date_filter`| — | `day`, `week`, `month`, or `year` |
|
||||
|
||||
## Using with AI Agents / curl
|
||||
## Using with AI Agents / LLMs
|
||||
|
||||
The `/api/search` endpoint returns clean JSON — ideal for LLMs and AI agents to consume directly.
|
||||
HeySearch is designed to be used by LLMs and AI agents. There are two integration methods:
|
||||
|
||||
```bash
|
||||
# Web search
|
||||
curl "http://localhost:8000/api/search?q=python+async&format=json" | jq
|
||||
### 1. MCP Tool Server (recommended)
|
||||
|
||||
# Restrict to specific engines
|
||||
curl "http://localhost:8000/api/search?q=rust+programming&engines=brave,google" | jq
|
||||
[Model Context Protocol](https://modelcontextprotocol.io/) (MCP) is the standard for LLM tool use. Add HeySearch to any MCP-compatible client:
|
||||
|
||||
# Image search
|
||||
curl "http://localhost:8000/api/search?q=mountain+landscape&category=images&image_size=large" | jq
|
||||
|
||||
# Paginate results
|
||||
curl "http://localhost:8000/api/search?q=machine+learning&page=2" | jq
|
||||
|
||||
# Extract just titles and URLs from web results
|
||||
curl "http://localhost:8000/api/search?q=openai" | \
|
||||
jq '[.results[] | {title, url, snippet}]'
|
||||
**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"heysearch": {
|
||||
"url": "http://localhost:8000/api/mcp",
|
||||
"transport": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Cursor / Continue / VS Code Copilot** — add `http://localhost:8000/api/mcp` as an MCP server URL in the tool settings.
|
||||
|
||||
**Manual test:**
|
||||
```bash
|
||||
# List available tools
|
||||
curl -X POST http://localhost:8000/api/mcp \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
||||
|
||||
# Call the search tool
|
||||
curl -X POST http://localhost:8000/api/mcp \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"query":"python async","num_results":3}}}'
|
||||
```
|
||||
|
||||
**Available MCP tools:** `search`, `autocomplete`
|
||||
|
||||
### 2. REST API with `format=llm`
|
||||
|
||||
For direct API calls from LLM agents, use `format=llm` to get a minimal, token-efficient response:
|
||||
|
||||
```bash
|
||||
# LLM-optimised response — only title, url, snippet, date. No engine noise.
|
||||
curl "http://localhost:8000/api/search?q=python+async&format=llm&max_results=5" | jq
|
||||
```
|
||||
|
||||
Response shape:
|
||||
```json
|
||||
{
|
||||
"query": "python async",
|
||||
"category": "web",
|
||||
"results": [
|
||||
{ "title": "...", "url": "https://...", "snippet": "...", "date": "2024-01-15" }
|
||||
],
|
||||
"total_results": 5
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Restrict to specific engines
|
||||
curl "http://localhost:8000/api/search?q=rust+programming&engines=brave,google&format=llm" | jq
|
||||
|
||||
# Image search with size filter
|
||||
curl "http://localhost:8000/api/search?q=mountain+landscape&category=images&image_size=large" | jq
|
||||
|
||||
# Limit results (hard limit, not a hint)
|
||||
curl "http://localhost:8000/api/search?q=openai&max_results=3&format=llm" | jq
|
||||
```
|
||||
|
||||
> Interactive API docs (Swagger UI) are available at `http://localhost:8000/docs`.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import SearchResponse, EngineInfo, APIError
|
||||
from app.models import SearchResponse, EngineInfo, APIError, LLMWebResult, LLMImageResult, LLMSearchResponse
|
||||
from app.search import search, get_autocomplete
|
||||
from app.engines import registry
|
||||
from app.excluded import get_excluded_domains, add_excluded_domain, remove_excluded_domain
|
||||
@@ -35,6 +35,12 @@ Supports both GET and POST methods with query parameters.
|
||||
curl '$BASE_URL/api/search?q=hello+world&category=web&page=1'
|
||||
```
|
||||
|
||||
**LLM / AI-agent optimised response (`format=llm`):**
|
||||
```bash
|
||||
curl '$BASE_URL/api/search?q=hello+world&format=llm&max_results=5'
|
||||
```
|
||||
Returns a minimal JSON response with only `query`, `results` (title, url, snippet, date), and `total_results` — ideal for RAG pipelines and tool-calling.
|
||||
|
||||
**Example response (truncated):**
|
||||
```json
|
||||
{
|
||||
@@ -75,17 +81,21 @@ async def api_search(
|
||||
category: Literal["web", "images"] = Query("web", description="Search category"),
|
||||
page: int = Query(1, ge=1, le=50, description="Page number"),
|
||||
pageNumber: int | None = Query(None, ge=1, le=50, description="Alias for page (1-based page number)"),
|
||||
numResults: int | None = Query(None, ge=1, le=100, description="Number of results requested (informational)"),
|
||||
format: str | None = Query(None, description="Response format hint (e.g. 'json')"),
|
||||
numResults: int | None = Query(None, ge=1, le=100, description="Number of results to return (applies as hard limit)"),
|
||||
max_results: int | None = Query(None, ge=1, le=100, description="Maximum number of results to return"),
|
||||
format: str | None = Query(None, description="Response format: 'llm' for a minimal LLM-friendly response, omit for full JSON"),
|
||||
imageProxy: bool | None = Query(None, description="Whether the client wants image proxying"),
|
||||
safesearch: str | None = Query(None, description="Safe search level (0=off, 1=moderate, 2=strict)"),
|
||||
engines: str | None = Query(None, description="Comma-separated engine names to use (e.g. 'google,bing')"),
|
||||
image_size: Literal["", "large", "medium", "small"] = Query("", description="Filter images by size (images category only)"),
|
||||
sort: Literal["default", "date_asc", "date_desc"] = Query("default", description="Sort results by publish date"),
|
||||
date_filter: Literal["", "day", "week", "month", "year"] = Query("", description="Filter results by publish date recency (day=24h, week=7d, month=30d, year=365d)"),
|
||||
):
|
||||
effective_page = pageNumber if pageNumber is not None else page
|
||||
engine_list = [e.strip() for e in engines.split(",")] if engines else None
|
||||
result = await search(q, category=category, page=effective_page, engines=engine_list, image_size=image_size, sort=sort)
|
||||
# max_results takes precedence; numResults is a supported alias
|
||||
effective_max = max_results if max_results is not None else numResults
|
||||
result = await search(q, category=category, page=effective_page, engines=engine_list, image_size=image_size, sort=sort, date_filter=date_filter, max_results=effective_max)
|
||||
origin_ip = request.client.host if request.client else ""
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
_stats.record_search(
|
||||
@@ -96,6 +106,33 @@ async def api_search(
|
||||
result_count=result.total_results,
|
||||
cached=result.cached,
|
||||
)
|
||||
|
||||
if format == "llm":
|
||||
llm_results: list[LLMWebResult | LLMImageResult] = []
|
||||
for r in result.results:
|
||||
if category == "images":
|
||||
llm_results.append(LLMImageResult(
|
||||
title=r.title,
|
||||
url=r.url,
|
||||
img_src=getattr(r, "img_src", ""),
|
||||
date=r.published_date or "",
|
||||
))
|
||||
else:
|
||||
llm_results.append(LLMWebResult(
|
||||
title=r.title,
|
||||
url=r.url,
|
||||
snippet=getattr(r, "content", ""),
|
||||
date=r.published_date or "",
|
||||
))
|
||||
# Return JSONResponse directly to bypass response_model=SearchResponse
|
||||
# coercion, which would otherwise strip LLM-only fields (snippet, date).
|
||||
return JSONResponse(content=LLMSearchResponse(
|
||||
query=result.query,
|
||||
category=result.category,
|
||||
results=llm_results,
|
||||
total_results=len(llm_results),
|
||||
).model_dump())
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from fastapi.responses import JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import router
|
||||
from app.mcp_server import router as mcp_router
|
||||
from app.engines import registry
|
||||
from app.excluded import init_db
|
||||
from app.settings import init_settings_table, set_setting
|
||||
@@ -131,6 +132,7 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
|
||||
|
||||
app.include_router(router, prefix="/api")
|
||||
app.include_router(mcp_router, prefix="/api")
|
||||
|
||||
# Alias /search → /api/search for compatibility with external clients
|
||||
@app.api_route("/search", methods=["GET", "POST"], include_in_schema=False)
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"""MCP (Model Context Protocol) server for HeySearch.
|
||||
|
||||
Exposes HeySearch as an MCP tool server so LLMs (Claude, Cursor, Continue,
|
||||
VS Code Copilot, etc.) can invoke search directly via the standard MCP
|
||||
JSON-RPC 2.0 protocol.
|
||||
|
||||
Transport: Streamable HTTP — clients POST JSON-RPC messages to /mcp.
|
||||
|
||||
Supported methods:
|
||||
initialize — capability negotiation
|
||||
tools/list — enumerate available tools
|
||||
tools/call — invoke a tool (search, autocomplete)
|
||||
ping — liveness check
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["MCP"])
|
||||
|
||||
MCP_PROTOCOL_VERSION = "2024-11-05"
|
||||
SERVER_INFO = {"name": "HeySearch", "version": "1.4.0"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool definitions (JSON Schema)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SEARCH_TOOL: dict[str, Any] = {
|
||||
"name": "search",
|
||||
"description": (
|
||||
"Search the web or images using the HeySearch privacy-respecting "
|
||||
"metasearch engine. Results are aggregated from Brave, DuckDuckGo, "
|
||||
"Google, and Bing and deduplicated. Returns titles, URLs, snippets, "
|
||||
"and publication dates."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query string.",
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["web", "images"],
|
||||
"default": "web",
|
||||
"description": "Search category: 'web' for text results, 'images' for image results.",
|
||||
},
|
||||
"num_results": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"default": 5,
|
||||
"description": "Maximum number of results to return (1–20).",
|
||||
},
|
||||
"engines": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Comma-separated engine names to restrict the search "
|
||||
"(e.g. 'google,bing'). Omit to use all enabled engines."
|
||||
),
|
||||
},
|
||||
"sort": {
|
||||
"type": "string",
|
||||
"enum": ["default", "date_asc", "date_desc"],
|
||||
"default": "default",
|
||||
"description": "Sort order: default (relevance), date_asc, or date_desc.",
|
||||
},
|
||||
"date_filter": {
|
||||
"type": "string",
|
||||
"enum": ["", "day", "week", "month", "year"],
|
||||
"default": "",
|
||||
"description": "Filter results by recency: day (24 h), week, month, or year.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
|
||||
_AUTOCOMPLETE_TOOL: dict[str, Any] = {
|
||||
"name": "autocomplete",
|
||||
"description": (
|
||||
"Get search query autocomplete suggestions from HeySearch. "
|
||||
"Useful for expanding or refining a partial query."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Partial search query to get suggestions for.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
|
||||
_ALL_TOOLS = [_SEARCH_TOOL, _AUTOCOMPLETE_TOOL]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ok(req_id: Any, result: Any) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
||||
|
||||
|
||||
def _err(req_id: Any, code: int, message: str) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
def _tool_result(text: str, is_error: bool = False) -> dict:
|
||||
return {"content": [{"type": "text", "text": text}], "isError": is_error}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _handle_search(arguments: dict) -> str:
|
||||
from app.search import search # local import to avoid circular deps
|
||||
|
||||
query: str = arguments.get("query", "").strip()
|
||||
if not query:
|
||||
return "Error: 'query' argument is required."
|
||||
|
||||
category: str = arguments.get("category", "web")
|
||||
num_results: int = min(int(arguments.get("num_results", 5)), 20)
|
||||
engines_str: str | None = arguments.get("engines")
|
||||
sort: str = arguments.get("sort", "default")
|
||||
date_filter: str = arguments.get("date_filter", "")
|
||||
|
||||
engine_list = [e.strip() for e in engines_str.split(",")] if engines_str else None
|
||||
|
||||
result = await search(
|
||||
query,
|
||||
category=category,
|
||||
page=1,
|
||||
engines=engine_list,
|
||||
sort=sort,
|
||||
date_filter=date_filter,
|
||||
max_results=num_results,
|
||||
)
|
||||
|
||||
if not result.results:
|
||||
return f"No results found for: {query}"
|
||||
|
||||
lines: list[str] = [f"Search results for: {query}\n"]
|
||||
for i, r in enumerate(result.results, 1):
|
||||
lines.append(f"{i}. {r.title}")
|
||||
lines.append(f" URL: {r.url}")
|
||||
snippet = getattr(r, "content", "") or getattr(r, "img_src", "")
|
||||
if snippet:
|
||||
lines.append(f" {snippet}")
|
||||
if r.published_date:
|
||||
lines.append(f" Date: {r.published_date}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _handle_autocomplete(arguments: dict) -> str:
|
||||
from app.search import get_autocomplete # local import
|
||||
|
||||
query: str = arguments.get("query", "").strip()
|
||||
if not query:
|
||||
return "Error: 'query' argument is required."
|
||||
|
||||
suggestions = await get_autocomplete(query)
|
||||
if not suggestions:
|
||||
return f"No suggestions found for: {query}"
|
||||
|
||||
return "Suggestions:\n" + "\n".join(f"- {s}" for s in suggestions)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _dispatch(req: dict) -> dict | None:
|
||||
"""Handle one JSON-RPC request object. Returns None for notifications."""
|
||||
method: str = req.get("method", "")
|
||||
req_id = req.get("id")
|
||||
params: dict = req.get("params") or {}
|
||||
|
||||
# Notifications (no id) — acknowledge silently
|
||||
if req_id is None:
|
||||
return None
|
||||
|
||||
if method == "initialize":
|
||||
return _ok(req_id, {
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": SERVER_INFO,
|
||||
})
|
||||
|
||||
if method == "ping":
|
||||
return _ok(req_id, {})
|
||||
|
||||
if method == "tools/list":
|
||||
cursor = params.get("cursor") # pagination cursor (unused — all tools fit in one page)
|
||||
return _ok(req_id, {"tools": _ALL_TOOLS})
|
||||
|
||||
if method == "tools/call":
|
||||
tool_name: str = params.get("name", "")
|
||||
arguments: dict = params.get("arguments") or {}
|
||||
|
||||
if tool_name == "search":
|
||||
try:
|
||||
text = await _handle_search(arguments)
|
||||
return _ok(req_id, _tool_result(text))
|
||||
except Exception as exc:
|
||||
logger.exception("MCP search tool error")
|
||||
return _ok(req_id, _tool_result(f"Search failed: {exc}", is_error=True))
|
||||
|
||||
if tool_name == "autocomplete":
|
||||
try:
|
||||
text = await _handle_autocomplete(arguments)
|
||||
return _ok(req_id, _tool_result(text))
|
||||
except Exception as exc:
|
||||
logger.exception("MCP autocomplete tool error")
|
||||
return _ok(req_id, _tool_result(f"Autocomplete failed: {exc}", is_error=True))
|
||||
|
||||
return _err(req_id, -32601, f"Unknown tool: {tool_name}")
|
||||
|
||||
return _err(req_id, -32601, f"Method not found: {method}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/mcp",
|
||||
summary="MCP tool server",
|
||||
description="""[Model Context Protocol](https://modelcontextprotocol.io/) (MCP) endpoint.
|
||||
|
||||
Exposes HeySearch as an MCP tool server. LLMs and AI coding assistants
|
||||
(Claude Desktop, Cursor, Continue, VS Code Copilot, etc.) can add this
|
||||
server to their MCP configuration to invoke search directly.
|
||||
|
||||
**Transport:** Streamable HTTP — POST JSON-RPC 2.0 messages to this endpoint.
|
||||
|
||||
**Available tools:** `search`, `autocomplete`
|
||||
|
||||
**Quick config example (Claude Desktop / `claude_desktop_config.json`):**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"heysearch": {
|
||||
"url": "$BASE_URL/api/mcp",
|
||||
"transport": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Manual test:**
|
||||
```bash
|
||||
# List available tools
|
||||
curl -X POST $BASE_URL/api/mcp \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
||||
|
||||
# Invoke the search tool
|
||||
curl -X POST $BASE_URL/api/mcp \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"query":"python async","num_results":3}}}'
|
||||
```
|
||||
""",
|
||||
include_in_schema=True,
|
||||
)
|
||||
async def mcp_endpoint(request: Request):
|
||||
"""Handle MCP JSON-RPC 2.0 requests (single or batch)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=_err(None, -32700, "Parse error: request body must be valid JSON"),
|
||||
)
|
||||
|
||||
# Batch request
|
||||
if isinstance(body, list):
|
||||
responses = [await _dispatch(req) for req in body if isinstance(req, dict)]
|
||||
responses = [r for r in responses if r is not None]
|
||||
if not responses:
|
||||
return Response(status_code=204)
|
||||
return JSONResponse(content=responses)
|
||||
|
||||
# Single request
|
||||
if not isinstance(body, dict):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=_err(None, -32600, "Invalid request: expected a JSON object or array"),
|
||||
)
|
||||
|
||||
result = await _dispatch(body)
|
||||
if result is None:
|
||||
# Notification — no response body
|
||||
return Response(status_code=204)
|
||||
return JSONResponse(content=result)
|
||||
@@ -84,3 +84,33 @@ class APIError(BaseModel):
|
||||
message: str
|
||||
details: str = ""
|
||||
retry_hint: str = ""
|
||||
|
||||
|
||||
# --- LLM-optimised response models ---
|
||||
|
||||
class LLMWebResult(BaseModel):
|
||||
"""A single web result stripped to the fields LLMs need."""
|
||||
title: str
|
||||
url: str
|
||||
snippet: str = ""
|
||||
date: str = ""
|
||||
|
||||
|
||||
class LLMImageResult(BaseModel):
|
||||
"""A single image result stripped to the fields LLMs need."""
|
||||
title: str
|
||||
url: str
|
||||
img_src: str
|
||||
date: str = ""
|
||||
|
||||
|
||||
class LLMSearchResponse(BaseModel):
|
||||
"""Minimal search response for LLM / AI-agent consumption.
|
||||
|
||||
Contains only the fields needed for RAG and tool-calling workflows.
|
||||
Omits engine metadata, error details, and other browser-UI noise.
|
||||
"""
|
||||
query: str
|
||||
category: str = "web"
|
||||
results: list[LLMWebResult | LLMImageResult] = Field(default_factory=list)
|
||||
total_results: int = 0
|
||||
|
||||
+58
-9
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
||||
import httpx
|
||||
@@ -17,6 +18,43 @@ from app import cache
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SortOrder = str # "default" | "date_asc" | "date_desc"
|
||||
DateFilter = str # "" | "day" | "week" | "month" | "year"
|
||||
|
||||
_DATE_FILTER_DELTAS: dict[str, timedelta] = {
|
||||
"day": timedelta(days=1),
|
||||
"week": timedelta(weeks=1),
|
||||
"month": timedelta(days=30),
|
||||
"year": timedelta(days=365),
|
||||
}
|
||||
|
||||
|
||||
def _parse_published_date(date_str: str) -> datetime | None:
|
||||
"""Parse a published_date string into an aware datetime, or return None."""
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(date_str)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _apply_date_filter(results: list, date_filter: DateFilter) -> None:
|
||||
"""Remove results whose published_date falls outside the requested window.
|
||||
|
||||
Results with no published_date are excluded when a filter is active, since
|
||||
their recency cannot be determined.
|
||||
"""
|
||||
delta = _DATE_FILTER_DELTAS.get(date_filter)
|
||||
if delta is None:
|
||||
return
|
||||
cutoff = datetime.now(tz=timezone.utc) - delta
|
||||
results[:] = [
|
||||
r for r in results
|
||||
if (dt := _parse_published_date(r.published_date)) is not None and dt >= cutoff
|
||||
]
|
||||
|
||||
|
||||
def _apply_sort(results: list, sort: SortOrder) -> None:
|
||||
@@ -73,17 +111,24 @@ async def search(
|
||||
engines: list[str] | None = None,
|
||||
image_size: str = "",
|
||||
sort: SortOrder = "default",
|
||||
date_filter: DateFilter = "",
|
||||
max_results: int | None = None,
|
||||
) -> SearchResponse:
|
||||
"""Search across all enabled engines concurrently, with optional Redis caching."""
|
||||
engines_key = ",".join(sorted(engines)) if engines else ""
|
||||
|
||||
# Check cache first (cache stores default-order results; sort applied after)
|
||||
cached_data = await cache.get_cached(query, category, page, image_size, engines_key)
|
||||
if cached_data is not None:
|
||||
resp = SearchResponse(**cached_data)
|
||||
resp.cached = True
|
||||
_apply_sort(resp.results, sort)
|
||||
return resp
|
||||
# Skip cache when a date filter is active — cached results may lack published_date
|
||||
# on many entries, causing the filter to produce sparse or empty result sets.
|
||||
if not date_filter:
|
||||
cached_data = await cache.get_cached(query, category, page, image_size, engines_key)
|
||||
if cached_data is not None:
|
||||
resp = SearchResponse(**cached_data)
|
||||
resp.cached = True
|
||||
_apply_sort(resp.results, sort)
|
||||
if max_results is not None:
|
||||
resp.results = resp.results[:max_results]
|
||||
resp.total_results = len(resp.results)
|
||||
return resp
|
||||
|
||||
enabled_engines = registry.get_enabled_engines()
|
||||
|
||||
@@ -153,12 +198,16 @@ async def search(
|
||||
has_next=any(s.result_count > 0 for s in all_stats if s.status == "ok"),
|
||||
)
|
||||
|
||||
# Store in cache before sorting (cache always holds default-order results)
|
||||
# Store in cache before filtering/sorting (cache always holds full, default-order results)
|
||||
if unique_results:
|
||||
await cache.set_cached(query, category, page, image_size, engines_key, response.model_dump())
|
||||
|
||||
# Apply requested sort order
|
||||
# Apply date filter, then sort
|
||||
_apply_date_filter(response.results, date_filter)
|
||||
_apply_sort(response.results, sort)
|
||||
if max_results is not None:
|
||||
response.results = response.results[:max_results]
|
||||
response.total_results = len(response.results)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
Generated
+11
-24
@@ -1921,37 +1921,24 @@
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz",
|
||||
"integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -3264,9 +3251,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
|
||||
"integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
|
||||
+112
-22
@@ -5,6 +5,7 @@ import { WebResults } from "@/components/WebResults";
|
||||
import { ImageResults } from "@/components/ImageResults";
|
||||
import { SearchStats } from "@/components/SearchStats";
|
||||
import { SettingsModal } from "@/components/SettingsModal";
|
||||
import { FilterSheet } from "@/components/FilterSheet";
|
||||
import { ErrorToast } from "@/components/ErrorToast";
|
||||
import { BackgroundGallery } from "@/components/BackgroundGallery";
|
||||
import { Bookmarks } from "@/components/Bookmarks";
|
||||
@@ -17,6 +18,7 @@ import { cn } from "@/lib/utils";
|
||||
type Category = "web" | "images";
|
||||
type ImageSize = "" | "large" | "medium" | "small";
|
||||
type SortOrder = "default" | "date_desc" | "date_asc";
|
||||
type DateFilter = "" | "day" | "week" | "month" | "year";
|
||||
|
||||
const IMAGE_SIZE_OPTIONS: { value: ImageSize; label: string }[] = [
|
||||
{ value: "", label: "All sizes" },
|
||||
@@ -31,7 +33,15 @@ const SORT_OPTIONS: { value: SortOrder; label: string }[] = [
|
||||
{ value: "date_asc", label: "Oldest" },
|
||||
];
|
||||
|
||||
function parseUrlState(): { q: string; cat: Category; page: number; imageSize: ImageSize; engines: string; sort: SortOrder } {
|
||||
const DATE_FILTER_OPTIONS: { value: DateFilter; label: string }[] = [
|
||||
{ value: "", label: "Any time" },
|
||||
{ value: "day", label: "Past day" },
|
||||
{ value: "week", label: "Past week" },
|
||||
{ value: "month", label: "Past month" },
|
||||
{ value: "year", label: "Past year" },
|
||||
];
|
||||
|
||||
function parseUrlState(): { q: string; cat: Category; page: number; imageSize: ImageSize; engines: string; sort: SortOrder; dateFilter: DateFilter } {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const q = params.get("q") ?? "";
|
||||
const cat = params.get("category") === "images" ? "images" : "web";
|
||||
@@ -41,10 +51,12 @@ function parseUrlState(): { q: string; cat: Category; page: number; imageSize: I
|
||||
const engines = params.get("engines") ?? "";
|
||||
const rawSort = params.get("sort") ?? "";
|
||||
const sort: SortOrder = (["date_desc", "date_asc"].includes(rawSort) ? rawSort : "default") as SortOrder;
|
||||
return { q, cat, page, imageSize, engines, sort };
|
||||
const rawDateFilter = params.get("date_filter") ?? "";
|
||||
const dateFilter: DateFilter = (["day", "week", "month", "year"].includes(rawDateFilter) ? rawDateFilter : "") as DateFilter;
|
||||
return { q, cat, page, imageSize, engines, sort, dateFilter };
|
||||
}
|
||||
|
||||
function pushUrl(q: string, cat: Category, page: number, imageSize: ImageSize = "", engines: string = "", sort: SortOrder = "default") {
|
||||
function pushUrl(q: string, cat: Category, page: number, imageSize: ImageSize = "", engines: string = "", sort: SortOrder = "default", dateFilter: DateFilter = "") {
|
||||
const params = new URLSearchParams();
|
||||
params.set("q", q);
|
||||
if (cat !== "web") params.set("category", cat);
|
||||
@@ -52,6 +64,7 @@ function pushUrl(q: string, cat: Category, page: number, imageSize: ImageSize =
|
||||
if (imageSize) params.set("image_size", imageSize);
|
||||
if (engines) params.set("engines", engines);
|
||||
if (sort !== "default") params.set("sort", sort);
|
||||
if (dateFilter) params.set("date_filter", dateFilter);
|
||||
const url = `/?${params.toString()}`;
|
||||
if (window.location.pathname + window.location.search !== url) {
|
||||
window.history.pushState(null, "", url);
|
||||
@@ -65,9 +78,11 @@ function App() {
|
||||
const [page, setPage] = useState(initial.page);
|
||||
const [imageSize, setImageSize] = useState<ImageSize>(initial.imageSize);
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>(initial.sort);
|
||||
const [dateFilter, setDateFilter] = useState<DateFilter>(initial.dateFilter);
|
||||
const [response, setResponse] = useState<SearchResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showFilterSheet, setShowFilterSheet] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(!!initial.q);
|
||||
const [showGallery, setShowGallery] = useState(window.location.pathname === "/backgrounds");
|
||||
const [showBookmarks, setShowBookmarks] = useState(window.location.pathname === "/bookmarks");
|
||||
@@ -172,7 +187,7 @@ function App() {
|
||||
|
||||
|
||||
const doSearch = useCallback(
|
||||
async (q: string, cat: Category = category, p: number = 1, size: ImageSize = imageSize, updateUrl = true, sort: SortOrder = sortOrder) => {
|
||||
async (q: string, cat: Category = category, p: number = 1, size: ImageSize = imageSize, updateUrl = true, sort: SortOrder = sortOrder, df: DateFilter = dateFilter) => {
|
||||
if (!q.trim()) return;
|
||||
setQuery(q);
|
||||
setCategory(cat);
|
||||
@@ -180,9 +195,9 @@ function App() {
|
||||
setImageSize(size);
|
||||
setLoading(true);
|
||||
setHasSearched(true);
|
||||
if (updateUrl) pushUrl(q, cat, p, cat === "images" ? size : "", "", sort);
|
||||
if (updateUrl) pushUrl(q, cat, p, cat === "images" ? size : "", "", sort, df);
|
||||
try {
|
||||
const res = await apiSearch(q, cat, p, cat === "images" ? size : "", sort);
|
||||
const res = await apiSearch(q, cat, p, cat === "images" ? size : "", sort, df);
|
||||
setResponse(res);
|
||||
} catch (err) {
|
||||
setResponse({
|
||||
@@ -202,13 +217,13 @@ function App() {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[category, imageSize, sortOrder]
|
||||
[category, imageSize, sortOrder, dateFilter]
|
||||
);
|
||||
|
||||
// Restore search from URL on initial load
|
||||
useEffect(() => {
|
||||
if (initial.q) {
|
||||
doSearch(initial.q, initial.cat, initial.page, initial.imageSize, false, initial.sort);
|
||||
doSearch(initial.q, initial.cat, initial.page, initial.imageSize, false, initial.sort, initial.dateFilter);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -236,10 +251,11 @@ function App() {
|
||||
setShowBookmarks(false);
|
||||
setShowStats(false);
|
||||
setShowHistory(false);
|
||||
const { q, cat, page: p, imageSize: size, sort } = parseUrlState();
|
||||
const { q, cat, page: p, imageSize: size, sort, dateFilter: df } = parseUrlState();
|
||||
if (q) {
|
||||
setSortOrder(sort);
|
||||
doSearch(q, cat, p, size, false, sort);
|
||||
setDateFilter(df);
|
||||
doSearch(q, cat, p, size, false, sort, df);
|
||||
} else {
|
||||
setHasSearched(false);
|
||||
setResponse(null);
|
||||
@@ -255,23 +271,28 @@ function App() {
|
||||
|
||||
const handleCategoryChange = (cat: Category) => {
|
||||
setCategory(cat);
|
||||
if (query) doSearch(query, cat, 1, cat === "images" ? imageSize : "", true, sortOrder);
|
||||
if (query) doSearch(query, cat, 1, cat === "images" ? imageSize : "", true, sortOrder, dateFilter);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage < 1) return;
|
||||
doSearch(query, category, newPage, imageSize);
|
||||
doSearch(query, category, newPage, imageSize, true, sortOrder, dateFilter);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const handleImageSizeChange = (size: ImageSize) => {
|
||||
setImageSize(size);
|
||||
if (query) doSearch(query, category, 1, size, true, sortOrder);
|
||||
if (query) doSearch(query, category, 1, size, true, sortOrder, dateFilter);
|
||||
};
|
||||
|
||||
const handleSortChange = (sort: SortOrder) => {
|
||||
setSortOrder(sort);
|
||||
if (query) doSearch(query, category, page, imageSize, true, sort);
|
||||
if (query) doSearch(query, category, page, imageSize, true, sort, dateFilter);
|
||||
};
|
||||
|
||||
const handleDateFilterChange = (df: DateFilter) => {
|
||||
setDateFilter(df);
|
||||
if (query) doSearch(query, category, 1, imageSize, true, sortOrder, df);
|
||||
};
|
||||
|
||||
const handleGoHome = () => {
|
||||
@@ -279,6 +300,7 @@ function App() {
|
||||
setResponse(null);
|
||||
setPage(1);
|
||||
setImageSize("");
|
||||
setDateFilter("");
|
||||
setShowGallery(false);
|
||||
setShowBookmarks(false);
|
||||
setShowStats(false);
|
||||
@@ -370,6 +392,21 @@ function App() {
|
||||
<SettingsModal open={showSettings} onClose={() => setShowSettings(false)} />
|
||||
);
|
||||
|
||||
const filterSheet = (
|
||||
<FilterSheet
|
||||
open={showFilterSheet}
|
||||
onClose={() => setShowFilterSheet(false)}
|
||||
category={category}
|
||||
imageSize={imageSize}
|
||||
sortOrder={sortOrder}
|
||||
dateFilter={dateFilter}
|
||||
onCategoryChange={(v) => { handleCategoryChange(v); setShowFilterSheet(false); }}
|
||||
onImageSizeChange={(v) => { handleImageSizeChange(v); setShowFilterSheet(false); }}
|
||||
onSortChange={(v) => { handleSortChange(v); setShowFilterSheet(false); }}
|
||||
onDateFilterChange={(v) => { handleDateFilterChange(v); setShowFilterSheet(false); }}
|
||||
/>
|
||||
);
|
||||
|
||||
// Gallery page
|
||||
if (showGallery) {
|
||||
return <>
|
||||
@@ -584,18 +621,47 @@ function App() {
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Image size filter — only visible in images category */}
|
||||
{/* Mobile: Filters button with active-count badge — hidden on sm+ */}
|
||||
{(() => {
|
||||
const activeCount = [
|
||||
category === "images" && imageSize !== "",
|
||||
sortOrder !== "default",
|
||||
dateFilter !== "",
|
||||
].filter(Boolean).length;
|
||||
return (
|
||||
<button
|
||||
onClick={() => setShowFilterSheet(true)}
|
||||
aria-label={`Filters${activeCount > 0 ? `, ${activeCount} active` : ""}`}
|
||||
className={cn(
|
||||
"sm:hidden shrink-0 ml-1 flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
activeCount > 0
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Filters
|
||||
{activeCount > 0 && (
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Image size filter — only visible in images category, desktop only */}
|
||||
{category === "images" && (
|
||||
<>
|
||||
<div className="mx-2 h-5 w-px shrink-0 bg-border" aria-hidden="true" />
|
||||
<SlidersHorizontal className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="mx-2 h-5 w-px shrink-0 bg-border hidden sm:block" aria-hidden="true" />
|
||||
<SlidersHorizontal className="h-3.5 w-3.5 shrink-0 text-muted-foreground hidden sm:block" aria-hidden="true" />
|
||||
{IMAGE_SIZE_OPTIONS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => handleImageSizeChange(value)}
|
||||
aria-pressed={imageSize === value}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
"hidden sm:block shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
imageSize === value
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent"
|
||||
@@ -607,17 +673,17 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sort order — available for both web and images */}
|
||||
{/* Sort order — desktop only */}
|
||||
<>
|
||||
<div className="mx-2 h-5 w-px shrink-0 bg-border" aria-hidden="true" />
|
||||
<span className="shrink-0 text-xs text-muted-foreground" aria-hidden="true">Sort:</span>
|
||||
<div className="mx-2 h-5 w-px shrink-0 bg-border hidden sm:block" aria-hidden="true" />
|
||||
<span className="shrink-0 text-xs text-muted-foreground hidden sm:block" aria-hidden="true">Sort:</span>
|
||||
{SORT_OPTIONS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => handleSortChange(value)}
|
||||
aria-pressed={sortOrder === value}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
"hidden sm:block shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
sortOrder === value
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent"
|
||||
@@ -627,6 +693,27 @@ function App() {
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
|
||||
{/* Date filter — desktop only */}
|
||||
<>
|
||||
<div className="mx-2 h-5 w-px shrink-0 bg-border hidden sm:block" aria-hidden="true" />
|
||||
<span className="shrink-0 text-xs text-muted-foreground hidden sm:block" aria-hidden="true">Date:</span>
|
||||
{DATE_FILTER_OPTIONS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => handleDateFilterChange(value)}
|
||||
aria-pressed={dateFilter === value}
|
||||
className={cn(
|
||||
"hidden sm:block shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
dateFilter === value
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -731,6 +818,9 @@ function App() {
|
||||
|
||||
{/* Settings modal */}
|
||||
{settingsModal}
|
||||
|
||||
{/* Mobile filter sheet */}
|
||||
{filterSheet}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Globe, ImageIcon, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Category = "web" | "images";
|
||||
type ImageSize = "" | "large" | "medium" | "small";
|
||||
type SortOrder = "default" | "date_desc" | "date_asc";
|
||||
type DateFilter = "" | "day" | "week" | "month" | "year";
|
||||
|
||||
interface FilterSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
category: Category;
|
||||
imageSize: ImageSize;
|
||||
sortOrder: SortOrder;
|
||||
dateFilter: DateFilter;
|
||||
onCategoryChange: (v: Category) => void;
|
||||
onImageSizeChange: (v: ImageSize) => void;
|
||||
onSortChange: (v: SortOrder) => void;
|
||||
onDateFilterChange: (v: DateFilter) => void;
|
||||
}
|
||||
|
||||
const IMAGE_SIZE_OPTIONS: { value: ImageSize; label: string }[] = [
|
||||
{ value: "", label: "All sizes" },
|
||||
{ value: "large", label: "Large" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "small", label: "Small" },
|
||||
];
|
||||
|
||||
const SORT_OPTIONS: { value: SortOrder; label: string }[] = [
|
||||
{ value: "default", label: "Default" },
|
||||
{ value: "date_desc", label: "Newest first" },
|
||||
{ value: "date_asc", label: "Oldest first" },
|
||||
];
|
||||
|
||||
const DATE_FILTER_OPTIONS: { value: DateFilter; label: string }[] = [
|
||||
{ value: "", label: "Any time" },
|
||||
{ value: "day", label: "Past day" },
|
||||
{ value: "week", label: "Past week" },
|
||||
{ value: "month", label: "Past month" },
|
||||
{ value: "year", label: "Past year" },
|
||||
];
|
||||
|
||||
function OptionRow<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
wrap = false,
|
||||
}: {
|
||||
options: { value: T; label: string }[];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
wrap?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex gap-2", wrap ? "flex-wrap" : "flex-wrap")}>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onChange(opt.value)}
|
||||
aria-pressed={value === opt.value}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
value === opt.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-accent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterSheet({
|
||||
open,
|
||||
onClose,
|
||||
category,
|
||||
imageSize,
|
||||
sortOrder,
|
||||
dateFilter,
|
||||
onCategoryChange,
|
||||
onImageSizeChange,
|
||||
onSortChange,
|
||||
onDateFilterChange,
|
||||
}: FilterSheetProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search filters"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 bg-black/50" />
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
className="relative z-10 w-full max-w-lg rounded-t-2xl bg-card shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="h-1 w-10 rounded-full bg-border" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b">
|
||||
<h2 className="text-base font-semibold">Search filters</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close filters"
|
||||
className="rounded-full p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter groups */}
|
||||
<div className="overflow-y-auto max-h-[60vh] px-5 py-4 space-y-5">
|
||||
{/* Category */}
|
||||
<section aria-labelledby="filter-category-label">
|
||||
<p id="filter-category-label" className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Category
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{([
|
||||
{ key: "web" as const, label: "Web", icon: Globe },
|
||||
{ key: "images" as const, label: "Images", icon: ImageIcon },
|
||||
]).map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => { onCategoryChange(key); }}
|
||||
aria-pressed={category === key}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
category === key
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-accent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Image size — only when images category */}
|
||||
{category === "images" && (
|
||||
<section aria-labelledby="filter-size-label">
|
||||
<p id="filter-size-label" className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Image size
|
||||
</p>
|
||||
<OptionRow options={IMAGE_SIZE_OPTIONS} value={imageSize} onChange={onImageSizeChange} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Sort */}
|
||||
<section aria-labelledby="filter-sort-label">
|
||||
<p id="filter-sort-label" className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Sort
|
||||
</p>
|
||||
<OptionRow options={SORT_OPTIONS} value={sortOrder} onChange={onSortChange} />
|
||||
</section>
|
||||
|
||||
{/* Date */}
|
||||
<section aria-labelledby="filter-date-label">
|
||||
<p id="filter-date-label" className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Date published
|
||||
</p>
|
||||
<OptionRow options={DATE_FILTER_OPTIONS} value={dateFilter} onChange={onDateFilterChange} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Done button */}
|
||||
<div className="px-5 py-4 border-t">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full rounded-xl bg-primary py-2.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -74,11 +74,13 @@ export async function search(
|
||||
category: "web" | "images" = "web",
|
||||
page: number = 1,
|
||||
imageSize: string = "",
|
||||
sort: string = "default"
|
||||
sort: string = "default",
|
||||
dateFilter: string = ""
|
||||
): Promise<SearchResponse> {
|
||||
const params = new URLSearchParams({ q: query, category, page: String(page) });
|
||||
if (imageSize) params.set("image_size", imageSize);
|
||||
if (sort !== "default") params.set("sort", sort);
|
||||
if (dateFilter) params.set("date_filter", dateFilter);
|
||||
const resp = await fetch(`${API_BASE}/search?${params}`);
|
||||
if (!resp.ok) throw new Error(`Search failed: ${resp.status}`);
|
||||
return resp.json();
|
||||
|
||||
Reference in New Issue
Block a user