Add AI functionality; fuck up UI royally, still a piece of shit.

This commit is contained in:
Ned Halksworth
2026-05-04 20:00:31 +01:00
parent e0f2eedcd9
commit 0fe063fed5
10 changed files with 1528 additions and 405 deletions
+3
View File
@@ -0,0 +1,3 @@
OLLAMA_API_KEY=
OLLAMA_DEFAULT_MODEL=gpt-oss:120b
OLLAMA_API_BASE=https://ollama.com
+1
View File
@@ -14,3 +14,4 @@ backend/venv/
.env .env
.env.* .env.*
!.env.example
+30 -1
View File
@@ -1,6 +1,6 @@
# sFetch # sFetch
sFetch is a full-stack search engine prototype with a lightweight Google/DDG-inspired frontend, a FastAPI search API, and an async crawler that indexes pages into a local SQLite FTS5 database. sFetch is a full-stack search engine prototype with a serious search interface, a FastAPI search API, Ollama Cloud-powered AI answers, and an async crawler that indexes pages into a local SQLite FTS5 database.
On first backend launch, sFetch downloads the latest Tranco top-site list, filters pornographic/adult domains, and seeds up to 1,000 non-adult sites if that seed has not already been recorded in the database. On first backend launch, sFetch downloads the latest Tranco top-site list, filters pornographic/adult domains, and seeds up to 1,000 non-adult sites if that seed has not already been recorded in the database.
@@ -11,6 +11,7 @@ sFetch/
├── backend/ ├── backend/
│ ├── main.py │ ├── main.py
│ ├── crawler.py │ ├── crawler.py
│ ├── ollama_cloud.py
│ ├── top_sites.py │ ├── top_sites.py
│ ├── content_filter.py │ ├── content_filter.py
│ ├── indexer.py │ ├── indexer.py
@@ -21,6 +22,7 @@ sFetch/
│ └── requirements.txt │ └── requirements.txt
├── frontend/ ├── frontend/
│ ├── index.html │ ├── index.html
│ ├── ai.html
│ └── results.html │ └── results.html
└── README.md └── README.md
``` ```
@@ -46,6 +48,23 @@ sFetch/
The frontend uses `const API_BASE = "http://localhost:8000";` at the top of each page script. The frontend uses `const API_BASE = "http://localhost:8000";` at the top of each page script.
## Ollama Cloud AI
sFetch reads Ollama Cloud credentials from environment variables. Do not hardcode API keys into source files.
```bash
export OLLAMA_API_KEY=your_api_key
export OLLAMA_DEFAULT_MODEL=gpt-oss:120b
```
AI features:
- `GET /ai/models` loads all models currently returned by Ollama Cloud's `/api/tags`.
- `POST /ai/search` generates an AI answer for search results using local indexed results and optional Ollama web search context.
- `POST /ai/search/stream` streams a search-grounded answer as server-sent events.
- `POST /ai/chat` powers the dedicated AI chat page at `frontend/ai.html`, with model selection and optional web search context.
- `POST /ai/chat/stream` streams chat responses as server-sent events.
## Crawling ## Crawling
The home page has index controls for: The home page has index controls for:
@@ -92,6 +111,12 @@ The crawler:
| `POST` | `/crawl/top-sites` | Queue the top-site seed crawl | | `POST` | `/crawl/top-sites` | Queue the top-site seed crawl |
| `GET` | `/crawl/top-sites/status` | Check top-site seed state | | `GET` | `/crawl/top-sites/status` | Check top-site seed state |
| `GET` | `/stats` | Total indexed pages and latest index time | | `GET` | `/stats` | Total indexed pages and latest index time |
| `GET` | `/ai/config` | Check Ollama Cloud configuration |
| `GET` | `/ai/models` | List available Ollama Cloud models |
| `POST` | `/ai/search` | Generate an AI answer for a search query |
| `POST` | `/ai/search/stream` | Stream an AI answer for a search query |
| `POST` | `/ai/chat` | Generate an AI chat response |
| `POST` | `/ai/chat/stream` | Stream an AI chat response |
## Configuration ## Configuration
@@ -107,6 +132,9 @@ sFetch's crawl and storage behavior lives in `backend/config.py`:
| `TOP_SITE_SOURCE_URL` | Top-site list source | | `TOP_SITE_SOURCE_URL` | Top-site list source |
| `TOP_SITE_SEED_LIMIT` | Number of safe top sites to seed | | `TOP_SITE_SEED_LIMIT` | Number of safe top sites to seed |
| `USER_AGENT` | User agent sent by `sFetchBot` | | `USER_AGENT` | User agent sent by `sFetchBot` |
| `OLLAMA_API_BASE` | Ollama Cloud API base URL |
| `OLLAMA_API_KEY` | API key used for authenticated Ollama Cloud calls |
| `OLLAMA_DEFAULT_MODEL` | Default model selected in AI features |
## Tech Stack ## Tech Stack
@@ -114,6 +142,7 @@ sFetch's crawl and storage behavior lives in `backend/config.py`:
| --- | --- | | --- | --- |
| Frontend | HTML, TailwindCSS CDN, Vanilla JavaScript | | Frontend | HTML, TailwindCSS CDN, Vanilla JavaScript |
| Backend | Python, FastAPI | | Backend | Python, FastAPI |
| AI | Ollama Cloud API |
| Crawler | Python, `httpx`, `BeautifulSoup4`, `asyncio` | | Crawler | Python, `httpx`, `BeautifulSoup4`, `asyncio` |
| Search Index | SQLite FTS5 via `aiosqlite` | | Search Index | SQLite FTS5 via `aiosqlite` |
| Top Sites | Tranco daily top-site ZIP with bundled fallback | | Top Sites | Tranco daily top-site ZIP with bundled fallback |
+13
View File
@@ -1,9 +1,18 @@
"""Application-wide configuration for sFetch.""" """Application-wide configuration for sFetch."""
import os
from pathlib import Path from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
try:
from dotenv import load_dotenv
load_dotenv(BASE_DIR.parent / ".env")
load_dotenv(BASE_DIR / ".env")
except ImportError:
pass
MAX_CRAWL_DEPTH = 2 MAX_CRAWL_DEPTH = 2
MAX_PAGES_PER_DOMAIN = 50 MAX_PAGES_PER_DOMAIN = 50
CRAWL_DELAY_SECONDS = 1.0 CRAWL_DELAY_SECONDS = 1.0
@@ -14,6 +23,10 @@ TOP_SITE_SOURCE_URL = "https://tranco-list.eu/top-1m.csv.zip"
TOP_SITE_SEED_LIMIT = 1000 TOP_SITE_SEED_LIMIT = 1000
TOP_SITE_DOWNLOAD_TIMEOUT_SECONDS = 30.0 TOP_SITE_DOWNLOAD_TIMEOUT_SECONDS = 30.0
TOP_SITE_SEED_META_KEY = "top_site_seed_v1" TOP_SITE_SEED_META_KEY = "top_site_seed_v1"
OLLAMA_API_BASE = os.getenv("OLLAMA_API_BASE", "https://ollama.com").rstrip("/")
OLLAMA_API_KEY = os.getenv("OLLAMA_API_KEY", "")
OLLAMA_DEFAULT_MODEL = os.getenv("OLLAMA_DEFAULT_MODEL", "gpt-oss:120b")
OLLAMA_REQUEST_TIMEOUT_SECONDS = 90.0
ADULT_DOMAINS = { ADULT_DOMAINS = {
"pornhub.com", "xvideos.com", "xnxx.com", "xhamster.com", "redtube.com", "pornhub.com", "xvideos.com", "xnxx.com", "xhamster.com", "redtube.com",
+302 -1
View File
@@ -3,10 +3,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
from collections.abc import AsyncIterator
from datetime import UTC, datetime from datetime import UTC, datetime
from fastapi import FastAPI, HTTPException, Query, BackgroundTasks from fastapi import FastAPI, HTTPException, Query, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from crawler import sFetchBot from crawler import sFetchBot
from config import TOP_SITE_SEED_LIMIT, TOP_SITE_SEED_META_KEY from config import TOP_SITE_SEED_LIMIT, TOP_SITE_SEED_META_KEY
@@ -19,7 +22,16 @@ from database import (
init_db, init_db,
set_meta_value, set_meta_value,
) )
from models import CrawlRequest, SearchResponse from models import AIAnswerResponse, AIChatRequest, AISearchRequest, AISource, CrawlRequest, SearchResponse
from ollama_cloud import (
OllamaCloudError,
chat as ollama_chat,
default_model,
is_ollama_configured,
list_models as list_ollama_models,
stream_chat as ollama_stream_chat,
web_search as ollama_web_search,
)
from searcher import search, search_images_api, search_videos_api from searcher import search, search_images_api, search_videos_api
from top_sites import load_top_site_seed_urls from top_sites import load_top_site_seed_urls
@@ -205,3 +217,292 @@ async def crawl_top_sites_status_endpoint() -> dict[str, object]:
async def stats_endpoint() -> dict[str, object]: async def stats_endpoint() -> dict[str, object]:
stats = await get_stats() stats = await get_stats()
return stats return stats
@app.get("/ai/config")
async def ai_config_endpoint() -> dict[str, object]:
return {
"configured": is_ollama_configured(),
"default_model": default_model(),
"provider": "Ollama Cloud",
}
@app.get("/ai/models")
async def ai_models_endpoint() -> dict[str, object]:
try:
models = await list_ollama_models()
except OllamaCloudError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
return {
"default_model": default_model(),
"models": models,
}
@app.post("/ai/chat", response_model=AIAnswerResponse)
async def ai_chat_endpoint(request: AIChatRequest) -> AIAnswerResponse:
model = (request.model or default_model()).strip()
if not model:
raise HTTPException(status_code=400, detail="Model is required.")
if not request.messages:
raise HTTPException(status_code=400, detail="At least one message is required.")
try:
messages, sources = await _build_chat_messages_and_sources(request)
response = await ollama_chat(model=model, messages=messages, think=request.think)
except OllamaCloudError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
message = response.get("message") or {}
return AIAnswerResponse(
model=response.get("model") or model,
content=message.get("content") or "",
thinking=message.get("thinking"),
sources=sources,
configured=is_ollama_configured(),
)
def _sse(event: str, data: object) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def _build_chat_messages_and_sources(request: AIChatRequest) -> tuple[list[dict[str, object]], list[AISource]]:
messages = [
message.model_dump(exclude_none=True)
for message in request.messages
if message.content.strip() or message.tool_calls
]
if not messages:
raise OllamaCloudError("At least one message is required.", status_code=400)
sources: list[AISource] = []
if request.use_web_search:
latest_user_message = next(
(message.content for message in reversed(request.messages) if message.role == "user" and message.content.strip()),
"",
)
if latest_user_message:
web_results = await ollama_web_search(latest_user_message, max_results=request.web_result_limit)
sources = [
AISource(
title=result.get("title") or result.get("url") or "Web result",
url=result.get("url") or "",
source_type="web",
content=result.get("content") or "",
)
for result in web_results
if result.get("url")
]
if sources:
context = "\n".join(_source_text(source, index) for index, source in enumerate(sources, start=1))
messages.insert(
0,
{
"role": "system",
"content": (
"Use the following web search context when it is relevant. "
"Cite sources inline using bracket numbers such as [1].\n\n"
f"{context}"
),
},
)
return messages, sources
async def _stream_ollama_events(
model: str,
messages: list[dict[str, object]],
think: bool | str | None,
sources: list[AISource],
) -> AsyncIterator[str]:
content = ""
thinking = ""
yield _sse(
"meta",
{
"model": model,
"configured": is_ollama_configured(),
"sources": [source.model_dump() for source in sources],
},
)
try:
async for chunk in ollama_stream_chat(model=model, messages=messages, think=think):
message = chunk.get("message") or {}
thinking_delta = message.get("thinking") or ""
content_delta = message.get("content") or ""
if thinking_delta:
thinking += thinking_delta
yield _sse("thinking", {"delta": thinking_delta})
if content_delta:
content += content_delta
yield _sse("content", {"delta": content_delta})
if chunk.get("done"):
yield _sse(
"done",
{
"model": chunk.get("model") or model,
"content": content,
"thinking": thinking,
"sources": [source.model_dump() for source in sources],
},
)
return
except OllamaCloudError as exc:
yield _sse("error", {"detail": str(exc), "status_code": exc.status_code})
except Exception as exc:
yield _sse("error", {"detail": f"Streaming failed: {exc}", "status_code": 502})
@app.post("/ai/chat/stream")
async def ai_chat_stream_endpoint(request: AIChatRequest) -> StreamingResponse:
model = (request.model or default_model()).strip()
if not model:
raise HTTPException(status_code=400, detail="Model is required.")
if not is_ollama_configured():
raise HTTPException(status_code=503, detail="Ollama Cloud is not configured. Set OLLAMA_API_KEY.")
try:
messages, sources = await _build_chat_messages_and_sources(request)
except OllamaCloudError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
return StreamingResponse(
_stream_ollama_events(model=model, messages=messages, think=request.think, sources=sources),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
def _source_text(source: AISource, index: int) -> str:
return (
f"[{index}] {source.title}\n"
f"Type: {source.source_type}\n"
f"URL: {source.url}\n"
f"Excerpt: {source.content[:1200]}\n"
)
async def _build_ai_search_sources(request: AISearchRequest) -> list[AISource]:
local_results = await search(query=request.query, limit=request.local_result_limit, offset=0)
sources = [
AISource(
title=result["title"],
url=result["url"],
source_type="local",
content=result["snippet"],
)
for result in local_results
]
if request.include_web:
web_results = await ollama_web_search(request.query, max_results=request.web_result_limit)
sources.extend(
AISource(
title=result.get("title") or result.get("url") or "Web result",
url=result.get("url") or "",
source_type="web",
content=result.get("content") or "",
)
for result in web_results
if result.get("url")
)
return sources
@app.post("/ai/search", response_model=AIAnswerResponse)
async def ai_search_endpoint(request: AISearchRequest) -> AIAnswerResponse:
model = (request.model or default_model()).strip()
query = request.query.strip()
if not model:
raise HTTPException(status_code=400, detail="Model is required.")
if not query:
raise HTTPException(status_code=400, detail="Query is required.")
try:
sources = await _build_ai_search_sources(request)
source_context = "\n".join(_source_text(source, index) for index, source in enumerate(sources, start=1))
if not source_context:
source_context = "No search sources were found for this query."
messages = [
{
"role": "system",
"content": (
"You are sFetch AI, a precise search assistant. Answer only from the provided sources. "
"Write in a neutral, professional tone. Keep the response concise. "
"Cite sources inline using bracket numbers such as [1]. "
"If the sources are insufficient, say what is missing rather than guessing."
),
},
{
"role": "user",
"content": f"Search query: {query}\n\nSources:\n{source_context}",
},
]
response = await ollama_chat(model=model, messages=messages, think=request.think)
except OllamaCloudError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
message = response.get("message") or {}
return AIAnswerResponse(
model=response.get("model") or model,
content=message.get("content") or "",
thinking=message.get("thinking"),
sources=sources,
configured=is_ollama_configured(),
)
async def _build_ai_search_messages(request: AISearchRequest) -> tuple[list[dict[str, str]], list[AISource]]:
sources = await _build_ai_search_sources(request)
source_context = "\n".join(_source_text(source, index) for index, source in enumerate(sources, start=1))
if not source_context:
source_context = "No search sources were found for this query."
messages = [
{
"role": "system",
"content": (
"You are sFetch AI, a precise search assistant. Answer only from the provided sources. "
"Write in a neutral, useful tone with direct synthesis. "
"Cite sources inline using bracket numbers such as [1]. "
"If sources are insufficient, say what is missing rather than guessing."
),
},
{
"role": "user",
"content": f"Search query: {request.query.strip()}\n\nSources:\n{source_context}",
},
]
return messages, sources
@app.post("/ai/search/stream")
async def ai_search_stream_endpoint(request: AISearchRequest) -> StreamingResponse:
model = (request.model or default_model()).strip()
query = request.query.strip()
if not model:
raise HTTPException(status_code=400, detail="Model is required.")
if not query:
raise HTTPException(status_code=400, detail="Query is required.")
if not is_ollama_configured():
raise HTTPException(status_code=503, detail="Ollama Cloud is not configured. Set OLLAMA_API_KEY.")
try:
messages, sources = await _build_ai_search_messages(request)
except OllamaCloudError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
return StreamingResponse(
_stream_ollama_events(model=model, messages=messages, think=request.think, sources=sources),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
+42
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -41,3 +43,43 @@ class CrawlRequest(BaseModel):
max_depth: int = Field(default=2, ge=0, le=5) max_depth: int = Field(default=2, ge=0, le=5)
max_pages_per_domain: int = Field(default=50, ge=1, le=500) max_pages_per_domain: int = Field(default=50, ge=1, le=500)
same_domain_only: bool = True same_domain_only: bool = True
class AIMessage(BaseModel):
role: Literal["system", "user", "assistant", "tool"]
content: str = ""
thinking: str | None = None
tool_name: str | None = None
tool_calls: list[dict[str, Any]] | None = None
class AIChatRequest(BaseModel):
model: str | None = None
messages: list[AIMessage] = Field(min_length=1)
think: bool | str | None = None
use_web_search: bool = False
web_result_limit: int = Field(default=5, ge=1, le=10)
class AISearchRequest(BaseModel):
query: str = Field(min_length=1)
model: str | None = None
include_web: bool = True
local_result_limit: int = Field(default=5, ge=1, le=10)
web_result_limit: int = Field(default=5, ge=1, le=10)
think: bool | str | None = None
class AISource(BaseModel):
title: str
url: str
source_type: Literal["local", "web"]
content: str = ""
class AIAnswerResponse(BaseModel):
model: str
content: str
thinking: str | None = None
sources: list[AISource] = Field(default_factory=list)
configured: bool = True
+138
View File
@@ -0,0 +1,138 @@
"""Ollama Cloud API client helpers for AI search and chat."""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from typing import Any
import httpx
from config import (
OLLAMA_API_BASE,
OLLAMA_API_KEY,
OLLAMA_DEFAULT_MODEL,
OLLAMA_REQUEST_TIMEOUT_SECONDS,
)
class OllamaCloudError(RuntimeError):
"""Raised when Ollama Cloud is unavailable or rejects a request."""
def __init__(self, message: str, status_code: int = 502) -> None:
super().__init__(message)
self.status_code = status_code
def is_ollama_configured() -> bool:
return bool(OLLAMA_API_KEY)
def default_model() -> str:
return OLLAMA_DEFAULT_MODEL
def _headers(require_auth: bool = False) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if OLLAMA_API_KEY:
headers["Authorization"] = f"Bearer {OLLAMA_API_KEY}"
elif require_auth:
raise OllamaCloudError(
"Ollama Cloud is not configured. Set OLLAMA_API_KEY before using AI responses.",
status_code=503,
)
return headers
def _normalise_error(response: httpx.Response) -> str:
try:
payload = response.json()
except ValueError:
return response.text.strip() or response.reason_phrase
detail = payload.get("error") or payload.get("detail") or payload
return str(detail)
async def list_models() -> list[dict[str, Any]]:
timeout = httpx.Timeout(OLLAMA_REQUEST_TIMEOUT_SECONDS)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(f"{OLLAMA_API_BASE}/api/tags", headers=_headers(require_auth=False))
if response.status_code >= 400:
raise OllamaCloudError(_normalise_error(response), status_code=response.status_code)
payload = response.json()
models = payload.get("models") or []
return sorted(models, key=lambda item: item.get("name") or item.get("model") or "")
async def chat(
model: str,
messages: list[dict[str, Any]],
think: bool | str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"stream": False,
}
if think is not None and think != "off":
payload["think"] = think
timeout = httpx.Timeout(OLLAMA_REQUEST_TIMEOUT_SECONDS)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(
f"{OLLAMA_API_BASE}/api/chat",
headers=_headers(require_auth=True),
json=payload,
)
if response.status_code >= 400:
raise OllamaCloudError(_normalise_error(response), status_code=response.status_code)
return response.json()
async def stream_chat(
model: str,
messages: list[dict[str, Any]],
think: bool | str | None = None,
) -> AsyncIterator[dict[str, Any]]:
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"stream": True,
}
if think is not None and think != "off":
payload["think"] = think
timeout = httpx.Timeout(OLLAMA_REQUEST_TIMEOUT_SECONDS)
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream(
"POST",
f"{OLLAMA_API_BASE}/api/chat",
headers=_headers(require_auth=True),
json=payload,
) as response:
if response.status_code >= 400:
body = (await response.aread()).decode("utf-8", errors="replace")
raise OllamaCloudError(body or response.reason_phrase, status_code=response.status_code)
async for line in response.aiter_lines():
clean_line = line.strip()
if not clean_line:
continue
try:
yield json.loads(clean_line)
except json.JSONDecodeError:
yield {"message": {"content": clean_line}}
async def web_search(query: str, max_results: int = 5) -> list[dict[str, Any]]:
timeout = httpx.Timeout(OLLAMA_REQUEST_TIMEOUT_SECONDS)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(
f"{OLLAMA_API_BASE}/api/web_search",
headers=_headers(require_auth=True),
json={"query": query, "max_results": max(1, min(max_results, 10))},
)
if response.status_code >= 400:
raise OllamaCloudError(_normalise_error(response), status_code=response.status_code)
payload = response.json()
return payload.get("results") or []
+446
View File
@@ -0,0 +1,446 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sFetch AI</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
shell: {
bg: "#f7f7f4",
panel: "#ffffff",
raised: "#fbfbf8",
ink: "#171717",
muted: "#6f6f68",
line: "#deded6",
soft: "#eeeeE8",
accent: "#315f95",
accentDark: "#244a75",
warm: "#8a5a20",
},
},
boxShadow: {
lift: "0 18px 55px rgba(23, 23, 23, 0.08)",
focus: "0 0 0 3px rgba(49, 95, 149, 0.16)",
},
},
},
};
</script>
<style>
:root { color-scheme: light; }
body {
background: #f7f7f4;
color: #171717;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.message-text p + p { margin-top: 0.8rem; }
.pulse-dot {
animation: pulse 1.1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.35; transform: scale(0.9); }
50% { opacity: 1; transform: scale(1); }
}
</style>
</head>
<body class="min-h-screen overflow-hidden">
<div class="grid h-screen grid-cols-1 lg:grid-cols-[280px_1fr]">
<aside class="hidden border-r border-shell-line bg-shell-raised lg:flex lg:flex-col">
<div class="border-b border-shell-line px-5 py-5">
<a href="./index.html" class="text-2xl font-semibold tracking-tight text-shell-ink">sFetch</a>
<p id="configText" class="mt-2 text-xs text-shell-muted">Checking Ollama Cloud...</p>
</div>
<nav class="space-y-1 p-3 text-sm">
<a href="./index.html" class="flex items-center justify-between rounded-lg px-3 py-2 font-medium text-shell-muted hover:bg-shell-soft hover:text-shell-ink">
Search
<span class="text-xs">/</span>
</a>
<a href="./ai.html" class="flex items-center justify-between rounded-lg bg-shell-soft px-3 py-2 font-medium text-shell-ink">
AI Chat
<span class="text-xs">active</span>
</a>
<a href="./results.html" class="flex items-center justify-between rounded-lg px-3 py-2 font-medium text-shell-muted hover:bg-shell-soft hover:text-shell-ink">
Results
<span class="text-xs">index</span>
</a>
</nav>
<div class="mt-auto border-t border-shell-line p-4">
<button id="newChatSidebar" class="w-full rounded-lg border border-shell-line bg-white px-4 py-2 text-sm font-semibold text-shell-ink transition hover:border-shell-accent hover:text-shell-accent">
New chat
</button>
</div>
</aside>
<main class="flex min-h-0 flex-col">
<header class="flex flex-col gap-3 border-b border-shell-line bg-shell-panel px-4 py-3 md:flex-row md:items-center md:justify-between">
<div class="flex items-center justify-between gap-3 lg:hidden">
<a href="./index.html" class="text-xl font-semibold tracking-tight text-shell-ink">sFetch</a>
<a href="./index.html" class="rounded-lg border border-shell-line px-3 py-2 text-sm font-medium text-shell-muted">Search</a>
</div>
<div class="grid gap-3 md:grid-cols-[minmax(220px,360px)_150px_170px] md:items-end">
<div>
<label for="modelSelect" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-shell-muted">Model</label>
<select id="modelSelect" class="w-full rounded-lg border border-shell-line bg-white px-3 py-2 text-sm text-shell-ink outline-none transition focus:border-shell-accent focus:shadow-focus">
<option value="">Loading models...</option>
</select>
</div>
<div>
<label for="thinkSelect" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-shell-muted">Think</label>
<select id="thinkSelect" class="w-full rounded-lg border border-shell-line bg-white px-3 py-2 text-sm text-shell-ink outline-none transition focus:border-shell-accent focus:shadow-focus">
<option value="off">Default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<label class="flex h-10 items-center gap-2 rounded-lg border border-shell-line bg-white px-3 text-sm font-medium text-shell-ink">
<input id="useWebSearch" type="checkbox" class="h-4 w-4 rounded border-shell-line text-shell-accent" />
Web search
</label>
</div>
<div class="hidden items-center gap-2 text-xs text-shell-muted md:flex">
<span id="streamStateDot" class="h-2 w-2 rounded-full bg-shell-muted"></span>
<span id="chatStatus">Ready</span>
</div>
</header>
<section id="messages" class="min-h-0 flex-1 overflow-y-auto px-4 py-6">
<div class="mx-auto flex max-w-4xl flex-col gap-5">
<div class="rounded-2xl border border-shell-line bg-shell-panel p-5 shadow-lift">
<p class="text-sm font-semibold uppercase tracking-wide text-shell-accent">sFetch AI</p>
<h1 class="mt-3 text-3xl font-semibold tracking-tight text-shell-ink">Ask, search, and synthesize.</h1>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<button data-prompt="Summarize the strongest search results for cloud model APIs." class="prompt-card rounded-xl border border-shell-line bg-shell-raised p-4 text-left text-sm leading-6 text-shell-muted transition hover:border-shell-accent hover:text-shell-ink">
Summarize indexed evidence
</button>
<button data-prompt="Compare the top sources and tell me what disagrees." class="prompt-card rounded-xl border border-shell-line bg-shell-raised p-4 text-left text-sm leading-6 text-shell-muted transition hover:border-shell-accent hover:text-shell-ink">
Compare conflicting sources
</button>
<button data-prompt="Research the latest context, cite sources, and give me the answer." class="prompt-card rounded-xl border border-shell-line bg-shell-raised p-4 text-left text-sm leading-6 text-shell-muted transition hover:border-shell-accent hover:text-shell-ink">
Research with web context
</button>
</div>
</div>
</div>
</section>
<footer class="border-t border-shell-line bg-shell-panel p-4">
<form id="chatForm" class="mx-auto max-w-4xl">
<div class="rounded-2xl border border-shell-line bg-white p-3 shadow-lift focus-within:border-shell-accent focus-within:shadow-focus">
<textarea id="messageInput" rows="3" placeholder="Message sFetch..." class="max-h-44 w-full resize-none bg-transparent px-2 py-2 text-base text-shell-ink outline-none placeholder:text-shell-muted"></textarea>
<div class="flex flex-col gap-3 border-t border-shell-line pt-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap gap-2 text-xs text-shell-muted">
<span id="modelHint" class="rounded-full bg-shell-soft px-3 py-1">Model loading</span>
<span id="streamHint" class="rounded-full bg-shell-soft px-3 py-1">Streaming on</span>
</div>
<div class="flex gap-2">
<button type="button" id="clearChat" class="rounded-lg border border-shell-line bg-white px-4 py-2 text-sm font-semibold text-shell-ink transition hover:bg-shell-soft">
Clear
</button>
<button type="submit" id="sendButton" class="rounded-lg bg-shell-accent px-5 py-2 text-sm font-semibold text-white transition hover:bg-shell-accentDark">
Send
</button>
</div>
</div>
</div>
</form>
</footer>
</main>
</div>
<script>
const API_BASE = "http://localhost:8000";
const messagesContainer = document.getElementById("messages");
const chatForm = document.getElementById("chatForm");
const messageInput = document.getElementById("messageInput");
const chatStatus = document.getElementById("chatStatus");
const sendButton = document.getElementById("sendButton");
const clearChat = document.getElementById("clearChat");
const newChatSidebar = document.getElementById("newChatSidebar");
const modelSelect = document.getElementById("modelSelect");
const thinkSelect = document.getElementById("thinkSelect");
const useWebSearch = document.getElementById("useWebSearch");
const configText = document.getElementById("configText");
const modelHint = document.getElementById("modelHint");
const streamStateDot = document.getElementById("streamStateDot");
let messages = [];
let currentAssistant = null;
function escapeHTML(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function scrollToBottom() {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
function setStatus(text, active = false) {
chatStatus.textContent = text;
streamStateDot.className = `h-2 w-2 rounded-full ${active ? "pulse-dot bg-shell-accent" : "bg-shell-muted"}`;
}
function formatContent(text) {
const safe = escapeHTML(text);
return safe
.split(/\n{2,}/)
.map((part) => `<p>${part.replaceAll("\n", "<br>")}</p>`)
.join("");
}
function messageShell(role) {
const isUser = role === "user";
const article = document.createElement("article");
article.className = `mx-auto flex w-full max-w-4xl gap-3 ${isUser ? "justify-end" : "justify-start"}`;
article.innerHTML = `
${isUser ? "" : '<div class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-shell-ink text-xs font-bold text-white">s</div>'}
<div class="${isUser ? "max-w-2xl rounded-2xl bg-shell-ink px-4 py-3 text-white" : "w-full max-w-3xl rounded-2xl border border-shell-line bg-shell-panel px-4 py-4 shadow-lift"}">
<div class="message-text text-sm leading-7 ${isUser ? "text-white" : "text-shell-ink"}"></div>
</div>
`;
messagesContainer.querySelector(".max-w-4xl")?.appendChild(article);
return article.querySelector(".message-text");
}
function renderUserMessage(content) {
const target = messageShell("user");
target.innerHTML = formatContent(content);
scrollToBottom();
}
function createAssistantMessage() {
const target = messageShell("assistant");
target.innerHTML = '<span class="text-shell-muted">Thinking...</span>';
currentAssistant = {
content: "",
thinking: "",
sources: [],
target,
details: null,
sourcesNode: null,
};
scrollToBottom();
}
function updateAssistantContent(delta) {
if (!currentAssistant) return;
currentAssistant.content += delta;
currentAssistant.target.innerHTML = formatContent(currentAssistant.content);
scrollToBottom();
}
function updateThinking(delta) {
if (!currentAssistant) return;
currentAssistant.thinking += delta;
if (!currentAssistant.details) {
currentAssistant.details = document.createElement("details");
currentAssistant.details.className = "mx-auto mt-2 w-full max-w-4xl rounded-xl border border-shell-line bg-shell-raised p-3";
currentAssistant.details.innerHTML = `
<summary class="cursor-pointer text-sm font-medium text-shell-muted">Reasoning trace</summary>
<pre class="mt-3 whitespace-pre-wrap text-xs leading-5 text-shell-muted"></pre>
`;
messagesContainer.querySelector(".max-w-4xl")?.appendChild(currentAssistant.details);
}
currentAssistant.details.querySelector("pre").textContent = currentAssistant.thinking;
}
function renderSources(sources) {
if (!currentAssistant || !sources.length) return;
currentAssistant.sources = sources;
const wrapper = document.createElement("div");
wrapper.className = "mx-auto mt-2 grid w-full max-w-4xl gap-2 md:grid-cols-2";
sources.slice(0, 8).forEach((source, index) => {
const link = document.createElement("a");
link.href = source.url;
link.target = "_blank";
link.rel = "noreferrer noopener";
link.className = "rounded-xl border border-shell-line bg-shell-panel p-3 text-sm transition hover:border-shell-accent";
link.innerHTML = `
<span class="font-semibold text-shell-ink">[${index + 1}] ${escapeHTML(source.title)}</span>
<span class="mt-1 block truncate text-xs text-shell-muted">${escapeHTML(source.url)}</span>
`;
wrapper.appendChild(link);
});
messagesContainer.querySelector(".max-w-4xl")?.appendChild(wrapper);
currentAssistant.sourcesNode = wrapper;
}
async function streamSSE(url, payload, handlers) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok || !response.body) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || "Model stream failed.");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop() || "";
for (const rawEvent of events) {
const eventName = (rawEvent.match(/^event: (.+)$/m) || [])[1] || "message";
const dataLine = rawEvent
.split("\n")
.filter((line) => line.startsWith("data: "))
.map((line) => line.slice(6))
.join("\n");
const data = dataLine ? JSON.parse(dataLine) : {};
handlers[eventName]?.(data);
}
}
}
async function loadModels() {
try {
const [configResponse, modelsResponse] = await Promise.all([
fetch(`${API_BASE}/ai/config`),
fetch(`${API_BASE}/ai/models`),
]);
const config = await configResponse.json();
const payload = await modelsResponse.json();
if (!modelsResponse.ok) throw new Error(payload.detail || "Unable to load models.");
modelSelect.innerHTML = "";
(payload.models || []).forEach((model) => {
const name = model.name || model.model;
if (!name) return;
const option = document.createElement("option");
option.value = name;
option.textContent = name;
if (name === payload.default_model) option.selected = true;
modelSelect.appendChild(option);
});
if (!modelSelect.options.length) {
modelSelect.innerHTML = `<option value="${payload.default_model || "gpt-oss:120b"}">${payload.default_model || "gpt-oss:120b"}</option>`;
}
configText.textContent = config.configured ? "Ollama Cloud connected" : "Ollama key missing";
modelHint.textContent = modelSelect.value || payload.default_model || "No model";
} catch (error) {
modelSelect.innerHTML = '<option value="gpt-oss:120b">gpt-oss:120b</option>';
configText.textContent = error.message || "Model loading failed";
modelHint.textContent = "gpt-oss:120b";
}
}
async function sendMessage(content) {
const userMessage = { role: "user", content };
messages.push(userMessage);
renderUserMessage(content);
createAssistantMessage();
setStatus("Streaming response", true);
sendButton.disabled = true;
try {
const think = thinkSelect.value === "off" ? null : thinkSelect.value;
let finalContent = "";
let finalSources = [];
await streamSSE(`${API_BASE}/ai/chat/stream`, {
model: modelSelect.value,
messages,
think,
use_web_search: useWebSearch.checked,
web_result_limit: 5,
}, {
meta(data) {
finalSources = data.sources || [];
if (finalSources.length) renderSources(finalSources);
},
thinking(data) {
updateThinking(data.delta || "");
},
content(data) {
finalContent += data.delta || "";
updateAssistantContent(data.delta || "");
},
done(data) {
finalContent = data.content || finalContent;
setStatus(`Response from ${data.model || modelSelect.value}`, false);
},
error(data) {
throw new Error(data.detail || "Model stream failed.");
},
});
messages.push({ role: "assistant", content: finalContent || currentAssistant?.content || "" });
} catch (error) {
updateAssistantContent(`\n\n${error.message || "Model stream failed."}`);
setStatus("Stream failed", false);
} finally {
sendButton.disabled = false;
messageInput.focus();
}
}
function clearConversation() {
messages = [];
messagesContainer.innerHTML = `
<div class="mx-auto flex max-w-4xl flex-col gap-5">
<div class="rounded-2xl border border-shell-line bg-shell-panel p-5 shadow-lift">
<p class="text-sm font-semibold uppercase tracking-wide text-shell-accent">sFetch AI</p>
<h1 class="mt-3 text-3xl font-semibold tracking-tight text-shell-ink">Ask, search, and synthesize.</h1>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<button data-prompt="Summarize the strongest search results for cloud model APIs." class="prompt-card rounded-xl border border-shell-line bg-shell-raised p-4 text-left text-sm leading-6 text-shell-muted transition hover:border-shell-accent hover:text-shell-ink">Summarize indexed evidence</button>
<button data-prompt="Compare the top sources and tell me what disagrees." class="prompt-card rounded-xl border border-shell-line bg-shell-raised p-4 text-left text-sm leading-6 text-shell-muted transition hover:border-shell-accent hover:text-shell-ink">Compare conflicting sources</button>
<button data-prompt="Research the latest context, cite sources, and give me the answer." class="prompt-card rounded-xl border border-shell-line bg-shell-raised p-4 text-left text-sm leading-6 text-shell-muted transition hover:border-shell-accent hover:text-shell-ink">Research with web context</button>
</div>
</div>
</div>
`;
bindPromptCards();
setStatus("Ready", false);
}
function bindPromptCards() {
document.querySelectorAll(".prompt-card").forEach((button) => {
button.addEventListener("click", () => {
messageInput.value = button.dataset.prompt || "";
messageInput.focus();
});
});
}
chatForm.addEventListener("submit", (event) => {
event.preventDefault();
const content = messageInput.value.trim();
if (!content) {
messageInput.focus();
return;
}
messageInput.value = "";
sendMessage(content);
});
clearChat.addEventListener("click", clearConversation);
newChatSidebar?.addEventListener("click", clearConversation);
modelSelect.addEventListener("change", () => {
modelHint.textContent = modelSelect.value || "No model";
});
bindPromptCards();
loadModels();
</script>
</body>
</html>
+214 -216
View File
@@ -10,221 +10,180 @@
theme: { theme: {
extend: { extend: {
colors: { colors: {
sfetch: { shell: {
bg: "#f8fafc", bg: "#f7f7f4",
surface: "#ffffff", panel: "#ffffff",
surfaceSoft: "#f1f5f9", raised: "#fbfbf8",
ink: "#202124", ink: "#171717",
muted: "#5f6368", muted: "#6f6f68",
border: "#dadce0", line: "#deded6",
blue: "#1a73e8", soft: "#eeeeE8",
orange: "#de5833", accent: "#315f95",
green: "#0b8043", accentDark: "#244a75",
warm: "#8a5a20",
}, },
}, },
boxShadow: { boxShadow: {
search: "0 2px 8px rgba(60, 64, 67, 0.14), 0 1px 3px rgba(60, 64, 67, 0.12)", lift: "0 18px 55px rgba(23, 23, 23, 0.08)",
panel: "0 16px 40px rgba(15, 23, 42, 0.08)", focus: "0 0 0 3px rgba(49, 95, 149, 0.16)",
}, },
}, },
}, },
}; };
</script> </script>
<style> <style>
:root { :root { color-scheme: light; }
color-scheme: light;
}
body { body {
background: #f8fafc; background: #f7f7f4;
color: #202124; color: #171717;
font-family: Arial, Helvetica, sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.brand {
font-family: Arial, Helvetica, sans-serif;
font-weight: 700;
letter-spacing: 0;
}
.brand span:nth-child(1) { color: #de5833; }
.brand span:nth-child(2) { color: #1a73e8; }
.brand span:nth-child(3) { color: #188038; }
.brand span:nth-child(4) { color: #fbbc04; }
.brand span:nth-child(5) { color: #1a73e8; }
.brand span:nth-child(6) { color: #de5833; }
.modal-open {
overflow: hidden;
} }
.modal-open { overflow: hidden; }
</style> </style>
</head> </head>
<body class="min-h-screen"> <body class="min-h-screen overflow-hidden">
<main class="flex min-h-screen flex-col"> <div class="grid h-screen grid-cols-1 lg:grid-cols-[280px_1fr]">
<header class="flex items-center justify-between px-5 py-4 text-sm text-sfetch-muted sm:px-8"> <aside class="hidden border-r border-shell-line bg-shell-raised lg:flex lg:flex-col">
<a href="./index.html" class="brand text-2xl" aria-label="sFetch home"> <div class="border-b border-shell-line px-5 py-5">
<span>s</span><span>F</span><span>e</span><span>t</span><span>c</span><span>h</span> <a href="./index.html" class="text-2xl font-semibold tracking-tight text-shell-ink">sFetch</a>
</a> <p id="aiConfigText" class="mt-2 text-xs text-shell-muted">Checking Ollama Cloud...</p>
<button </div>
id="openCrawlerModal"
class="rounded-full border border-sfetch-border bg-white px-4 py-2 font-medium text-sfetch-ink transition hover:border-sfetch-orange hover:text-sfetch-orange"
>
Index tools
</button>
</header>
<section class="mx-auto flex w-full max-w-5xl flex-1 flex-col items-center justify-center px-5 pb-24 pt-10"> <nav class="space-y-1 p-3 text-sm">
<h1 class="brand text-center text-6xl leading-none sm:text-7xl"> <a href="./index.html" class="flex items-center justify-between rounded-lg bg-shell-soft px-3 py-2 font-medium text-shell-ink">
<span>s</span><span>F</span><span>e</span><span>t</span><span>c</span><span>h</span> AI Search
</h1> <span class="text-xs">active</span>
</a>
<a href="./ai.html" class="flex items-center justify-between rounded-lg px-3 py-2 font-medium text-shell-muted hover:bg-shell-soft hover:text-shell-ink">
AI Chat
<span class="text-xs">stream</span>
</a>
<button id="openCrawlerModal" class="flex w-full items-center justify-between rounded-lg px-3 py-2 font-medium text-shell-muted hover:bg-shell-soft hover:text-shell-ink">
Index Admin
<span class="text-xs">crawl</span>
</button>
</nav>
<form id="searchForm" class="mt-9 w-full max-w-2xl"> <div class="mt-auto border-t border-shell-line p-4">
<label <section class="rounded-2xl border border-shell-line bg-white p-4">
for="searchInput" <p class="text-xs font-semibold uppercase tracking-wide text-shell-muted">Index</p>
class="flex min-h-14 items-center gap-3 rounded-full border border-sfetch-border bg-white px-5 transition focus-within:border-transparent focus-within:shadow-search" <p id="statsSummary" class="mt-2 text-sm leading-5 text-shell-muted">Checking index...</p>
> <div class="mt-3 h-2 overflow-hidden rounded-full bg-shell-soft">
<svg class="h-5 w-5 shrink-0 text-sfetch-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"> <div id="seedProgress" class="h-full w-0 bg-shell-accent transition-all duration-300"></div>
<circle cx="11" cy="11" r="6"></circle> </div>
<path d="M20 20L16.65 16.65"></path> <p id="seedStatus" class="mt-3 text-xs leading-5 text-shell-muted">Seed status unavailable.</p>
</svg> <button id="seedTopSites" class="mt-3 w-full rounded-lg bg-shell-ink px-3 py-2 text-sm font-semibold text-white transition hover:bg-black">
<input Seed top 1000
id="searchInput" </button>
type="text" </section>
autocomplete="off" </div>
placeholder="Search sFetch" </aside>
class="w-full bg-transparent text-base text-sfetch-ink outline-none placeholder:text-sfetch-muted sm:text-lg"
/>
</label>
<div class="mt-6 flex flex-wrap items-center justify-center gap-3"> <main class="flex min-h-0 flex-col">
<button <header class="flex flex-col gap-3 border-b border-shell-line bg-shell-panel px-4 py-3 md:flex-row md:items-center md:justify-between">
type="submit" <div class="flex items-center justify-between gap-3 lg:hidden">
class="rounded-md bg-sfetch-blue px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[#1558b0]" <a href="./index.html" class="text-xl font-semibold tracking-tight text-shell-ink">sFetch</a>
> <a href="./ai.html" class="rounded-lg border border-shell-line px-3 py-2 text-sm font-medium text-shell-muted">AI Chat</a>
sFetch Search
</button>
<button
type="button"
data-search-type="image"
class="rounded-md border border-sfetch-border bg-white px-5 py-2.5 text-sm font-medium text-sfetch-ink transition hover:border-sfetch-blue hover:text-sfetch-blue"
>
Images
</button>
<button
type="button"
data-search-type="video"
class="rounded-md border border-sfetch-border bg-white px-5 py-2.5 text-sm font-medium text-sfetch-ink transition hover:border-sfetch-blue hover:text-sfetch-blue"
>
Videos
</button>
</div> </div>
</form>
<section class="mt-12 w-full max-w-3xl rounded-lg border border-sfetch-border bg-white p-4 shadow-panel" aria-label="Index controls"> <div class="grid gap-3 md:grid-cols-[minmax(220px,360px)_150px] md:items-end">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<p class="text-xs font-semibold uppercase text-sfetch-orange">Index</p> <label for="modelSelect" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-shell-muted">Model</label>
<p id="statsSummary" class="mt-1 text-sm text-sfetch-muted">Checking index...</p> <select id="modelSelect" class="w-full rounded-lg border border-shell-line bg-white px-3 py-2 text-sm text-shell-ink outline-none transition focus:border-shell-accent focus:shadow-focus">
<option value="">Loading models...</option>
</select>
</div> </div>
<div class="flex flex-wrap gap-2"> <label class="flex h-10 items-center gap-2 rounded-lg border border-shell-line bg-white px-3 text-sm font-medium text-shell-ink">
<button <input id="includeAI" type="checkbox" checked class="h-4 w-4 rounded border-shell-line text-shell-accent" />
id="seedTopSites" AI answer
class="rounded-md bg-sfetch-orange px-4 py-2 text-sm font-medium text-white transition hover:bg-[#c44724]" </label>
> </div>
Seed top 1000
<div class="hidden items-center gap-2 text-xs text-shell-muted md:flex">
<span class="h-2 w-2 rounded-full bg-shell-accent"></span>
<span id="modelHint">Ready</span>
</div>
</header>
<section class="min-h-0 flex-1 overflow-y-auto px-4 py-8">
<div class="mx-auto flex max-w-4xl flex-col gap-6">
<section class="rounded-3xl border border-shell-line bg-shell-panel p-6 shadow-lift">
<p class="text-sm font-semibold uppercase tracking-wide text-shell-accent">AI Search Workspace</p>
<h1 class="mt-4 max-w-3xl text-4xl font-semibold tracking-tight text-shell-ink md:text-5xl">
Search the index. Stream the answer.
</h1>
<form id="searchForm" class="mt-8">
<div class="rounded-2xl border border-shell-line bg-white p-3 shadow-lift focus-within:border-shell-accent focus-within:shadow-focus">
<textarea id="searchInput" rows="4" placeholder="Ask a question or enter a search query..." class="max-h-44 w-full resize-none bg-transparent px-2 py-2 text-lg text-shell-ink outline-none placeholder:text-shell-muted"></textarea>
<div class="flex flex-col gap-3 border-t border-shell-line pt-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap gap-2 text-xs text-shell-muted">
<button type="button" data-search-type="all" class="mode-btn rounded-full bg-shell-soft px-3 py-1 font-medium text-shell-ink">All</button>
<button type="button" data-search-type="image" class="mode-btn rounded-full px-3 py-1 font-medium hover:bg-shell-soft hover:text-shell-ink">Images</button>
<button type="button" data-search-type="video" class="mode-btn rounded-full px-3 py-1 font-medium hover:bg-shell-soft hover:text-shell-ink">Videos</button>
</div>
<button type="submit" class="rounded-lg bg-shell-accent px-5 py-2 text-sm font-semibold text-white transition hover:bg-shell-accentDark">
Search
</button>
</div>
</div>
</form>
</section>
<div class="grid gap-4 md:grid-cols-3">
<button data-query="What are the latest results in the local index about AI search?" class="query-card rounded-2xl border border-shell-line bg-shell-panel p-4 text-left text-sm leading-6 text-shell-muted shadow-lift transition hover:border-shell-accent hover:text-shell-ink">
AI search status
</button> </button>
<button <button data-query="Compare the best indexed sources for cloud model APIs." class="query-card rounded-2xl border border-shell-line bg-shell-panel p-4 text-left text-sm leading-6 text-shell-muted shadow-lift transition hover:border-shell-accent hover:text-shell-ink">
id="openCrawlerModalSecondary" Compare sources
class="rounded-md border border-sfetch-border bg-white px-4 py-2 text-sm font-medium text-sfetch-ink transition hover:border-sfetch-orange hover:text-sfetch-orange" </button>
> <button data-query="Find indexed pages about Python and summarize the useful sources." class="query-card rounded-2xl border border-shell-line bg-shell-panel p-4 text-left text-sm leading-6 text-shell-muted shadow-lift transition hover:border-shell-accent hover:text-shell-ink">
Custom crawl Summarize Python sources
</button> </button>
</div> </div>
</div> </div>
<div class="mt-4 h-2 overflow-hidden rounded-full bg-sfetch-surfaceSoft">
<div id="seedProgress" class="h-full w-0 bg-sfetch-orange transition-all duration-300"></div>
</div>
<p id="seedStatus" class="mt-3 min-h-5 text-sm text-sfetch-muted">Top-site seed status unavailable.</p>
</section> </section>
</section> </main>
</div>
<footer class="border-t border-sfetch-border bg-white px-5 py-4 text-center text-xs text-sfetch-muted"> <div id="crawlerModal" class="pointer-events-none fixed inset-0 z-30 flex items-center justify-center bg-neutral-950/40 px-4 opacity-0 transition" aria-hidden="true">
&copy; 2026 sFetch <div class="w-full max-w-xl rounded-2xl border border-shell-line bg-white p-5 shadow-lift">
</footer> <div class="flex items-center justify-between gap-4 border-b border-shell-line pb-4">
</main> <h2 class="text-lg font-semibold text-shell-ink">Index Admin</h2>
<button id="closeCrawlerModal" class="flex h-9 w-9 items-center justify-center rounded-lg text-shell-muted transition hover:bg-shell-soft hover:text-shell-ink" aria-label="Close crawler modal">
<div
id="crawlerModal"
class="pointer-events-none fixed inset-0 z-30 flex items-center justify-center bg-slate-900/35 px-4 opacity-0 transition"
aria-hidden="true"
>
<div class="w-full max-w-xl rounded-lg border border-sfetch-border bg-white p-5 shadow-panel">
<div class="flex items-center justify-between gap-4 border-b border-sfetch-border pb-4">
<h2 class="text-lg font-semibold text-sfetch-ink">Custom crawl</h2>
<button
id="closeCrawlerModal"
class="flex h-9 w-9 items-center justify-center rounded-full text-sfetch-muted transition hover:bg-sfetch-surfaceSoft hover:text-sfetch-ink"
aria-label="Close crawler modal"
>
X X
</button> </button>
</div> </div>
<form id="crawlerForm" class="mt-5 space-y-4"> <form id="crawlerForm" class="mt-5 space-y-4">
<div> <div>
<label for="seedUrls" class="mb-2 block text-sm font-medium text-sfetch-ink">Seed URLs</label> <label for="seedUrls" class="mb-2 block text-sm font-medium text-shell-ink">Seed URLs</label>
<textarea <textarea id="seedUrls" rows="6" placeholder="https://example.com&#10;https://docs.python.org/" class="w-full rounded-lg border border-shell-line bg-white px-3 py-2 text-sm text-shell-ink outline-none transition focus:border-shell-accent focus:shadow-focus"></textarea>
id="seedUrls"
rows="6"
placeholder="https://example.com&#10;https://docs.python.org/"
class="w-full rounded-md border border-sfetch-border bg-white px-3 py-2 text-sm text-sfetch-ink outline-none transition focus:border-sfetch-blue focus:ring-2 focus:ring-blue-100"
></textarea>
</div> </div>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<div> <div>
<label for="crawlDepth" class="mb-2 block text-sm font-medium text-sfetch-ink">Max depth</label> <label for="crawlDepth" class="mb-2 block text-sm font-medium text-shell-ink">Max depth</label>
<input <input id="crawlDepth" type="number" min="0" max="5" value="2" class="w-full rounded-lg border border-shell-line bg-white px-3 py-2 text-sm text-shell-ink outline-none transition focus:border-shell-accent focus:shadow-focus" />
id="crawlDepth"
type="number"
min="0"
max="5"
value="2"
class="w-full rounded-md border border-sfetch-border bg-white px-3 py-2 text-sm text-sfetch-ink outline-none transition focus:border-sfetch-blue focus:ring-2 focus:ring-blue-100"
/>
</div> </div>
<div> <div>
<label for="maxPagesPerDomain" class="mb-2 block text-sm font-medium text-sfetch-ink">Pages per domain</label> <label for="maxPagesPerDomain" class="mb-2 block text-sm font-medium text-shell-ink">Pages per domain</label>
<input <input id="maxPagesPerDomain" type="number" min="1" max="500" value="50" class="w-full rounded-lg border border-shell-line bg-white px-3 py-2 text-sm text-shell-ink outline-none transition focus:border-shell-accent focus:shadow-focus" />
id="maxPagesPerDomain"
type="number"
min="1"
max="500"
value="50"
class="w-full rounded-md border border-sfetch-border bg-white px-3 py-2 text-sm text-sfetch-ink outline-none transition focus:border-sfetch-blue focus:ring-2 focus:ring-blue-100"
/>
</div> </div>
</div> </div>
<label class="flex items-center gap-3 text-sm text-sfetch-ink"> <label class="flex items-center gap-3 text-sm text-shell-ink">
<input id="sameDomainOnly" type="checkbox" checked class="h-4 w-4 rounded border-sfetch-border text-sfetch-blue" /> <input id="sameDomainOnly" type="checkbox" checked class="h-4 w-4 rounded border-shell-line text-shell-accent" />
Same domain only Same domain only
</label> </label>
<p id="crawlerStatus" class="min-h-5 text-sm text-sfetch-muted"></p> <p id="crawlerStatus" class="min-h-5 text-sm text-shell-muted"></p>
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"> <div class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button <button type="button" id="cancelCrawler" class="rounded-lg border border-shell-line bg-white px-4 py-2 text-sm font-semibold text-shell-ink transition hover:bg-shell-soft">
type="button"
id="cancelCrawler"
class="rounded-md border border-sfetch-border bg-white px-4 py-2 text-sm font-medium text-sfetch-ink transition hover:bg-sfetch-surfaceSoft"
>
Cancel Cancel
</button> </button>
<button <button type="submit" class="rounded-lg bg-shell-accent px-4 py-2 text-sm font-semibold text-white transition hover:bg-shell-accentDark">
type="submit"
class="rounded-md bg-sfetch-blue px-4 py-2 text-sm font-medium text-white transition hover:bg-[#1558b0]"
>
Launch crawl Launch crawl
</button> </button>
</div> </div>
@@ -237,8 +196,11 @@
const searchForm = document.getElementById("searchForm"); const searchForm = document.getElementById("searchForm");
const searchInput = document.getElementById("searchInput"); const searchInput = document.getElementById("searchInput");
const modelSelect = document.getElementById("modelSelect");
const includeAI = document.getElementById("includeAI");
const aiConfigText = document.getElementById("aiConfigText");
const modelHint = document.getElementById("modelHint");
const openCrawlerModal = document.getElementById("openCrawlerModal"); const openCrawlerModal = document.getElementById("openCrawlerModal");
const openCrawlerModalSecondary = document.getElementById("openCrawlerModalSecondary");
const closeCrawlerModal = document.getElementById("closeCrawlerModal"); const closeCrawlerModal = document.getElementById("closeCrawlerModal");
const cancelCrawler = document.getElementById("cancelCrawler"); const cancelCrawler = document.getElementById("cancelCrawler");
const crawlerModal = document.getElementById("crawlerModal"); const crawlerModal = document.getElementById("crawlerModal");
@@ -253,28 +215,69 @@
const seedProgress = document.getElementById("seedProgress"); const seedProgress = document.getElementById("seedProgress");
const seedTopSites = document.getElementById("seedTopSites"); const seedTopSites = document.getElementById("seedTopSites");
function runSearch(type = "all") { let selectedType = "all";
function runSearch(type = selectedType) {
const query = searchInput.value.trim(); const query = searchInput.value.trim();
if (!query) { if (!query) {
searchInput.focus(); searchInput.focus();
return; return;
} }
const params = new URLSearchParams({ q: query }); const params = new URLSearchParams({ q: query });
if (type !== "all") { if (type !== "all") params.set("type", type);
params.set("type", type); if (includeAI.checked && type === "all") {
params.set("ai", "1");
if (modelSelect.value) params.set("model", modelSelect.value);
} }
window.location.href = `results.html?${params.toString()}`; window.location.href = `results.html?${params.toString()}`;
} }
function setType(type) {
selectedType = type;
document.querySelectorAll(".mode-btn").forEach((button) => {
const active = button.dataset.searchType === type;
button.classList.toggle("bg-shell-soft", active);
button.classList.toggle("text-shell-ink", active);
});
}
function setModalOpen(isOpen) { function setModalOpen(isOpen) {
crawlerModal.classList.toggle("opacity-0", !isOpen); crawlerModal.classList.toggle("opacity-0", !isOpen);
crawlerModal.classList.toggle("pointer-events-none", !isOpen); crawlerModal.classList.toggle("pointer-events-none", !isOpen);
crawlerModal.setAttribute("aria-hidden", String(!isOpen)); crawlerModal.setAttribute("aria-hidden", String(!isOpen));
document.body.classList.toggle("modal-open", isOpen); document.body.classList.toggle("modal-open", isOpen);
if (isOpen) { if (isOpen) seedUrlsField.focus();
seedUrlsField.focus(); else crawlerStatus.textContent = "";
} else { }
crawlerStatus.textContent = "";
async function loadModels() {
try {
const [configResponse, modelsResponse] = await Promise.all([
fetch(`${API_BASE}/ai/config`),
fetch(`${API_BASE}/ai/models`),
]);
const config = await configResponse.json();
const payload = await modelsResponse.json();
const models = payload.models || [];
modelSelect.innerHTML = "";
models.forEach((model) => {
const name = model.name || model.model;
if (!name) return;
const option = document.createElement("option");
option.value = name;
option.textContent = name;
if (name === payload.default_model) option.selected = true;
modelSelect.appendChild(option);
});
if (!models.length) {
modelSelect.innerHTML = `<option value="${payload.default_model || "gpt-oss:120b"}">${payload.default_model || "gpt-oss:120b"}</option>`;
}
aiConfigText.textContent = config.configured ? "Ollama Cloud connected" : "Ollama key missing";
modelHint.textContent = modelSelect.value || payload.default_model || "Ready";
} catch {
modelSelect.innerHTML = '<option value="gpt-oss:120b">gpt-oss:120b</option>';
aiConfigText.textContent = "Model loading failed";
modelHint.textContent = "Model fallback";
} }
} }
@@ -282,11 +285,9 @@
try { try {
const response = await fetch(`${API_BASE}/stats`); const response = await fetch(`${API_BASE}/stats`);
const stats = await response.json(); const stats = await response.json();
if (!response.ok) { if (!response.ok) throw new Error();
throw new Error(); const lastIndexed = stats.last_indexed_at ? `Last indexed ${stats.last_indexed_at}` : "No timestamp";
} statsSummary.textContent = `${stats.total_pages.toLocaleString()} pages. ${lastIndexed}.`;
const lastIndexed = stats.last_indexed_at ? `, last indexed ${stats.last_indexed_at}` : "";
statsSummary.textContent = `${stats.total_pages.toLocaleString()} pages${lastIndexed}`;
} catch { } catch {
statsSummary.textContent = "Backend unavailable"; statsSummary.textContent = "Backend unavailable";
} }
@@ -296,17 +297,15 @@
try { try {
const response = await fetch(`${API_BASE}/crawl/top-sites/status`); const response = await fetch(`${API_BASE}/crawl/top-sites/status`);
const status = await response.json(); const status = await response.json();
if (!response.ok) { if (!response.ok) throw new Error();
throw new Error();
}
const total = Number(status.total || 0); const total = Number(status.total || 0);
const indexed = Number(status.indexed || 0); const indexed = Number(status.indexed || 0);
const percent = total > 0 && status.state === "complete" ? 100 : total > 0 ? Math.min(96, (indexed / total) * 100) : 0; const percent = total > 0 && status.state === "complete" ? 100 : total > 0 ? Math.min(96, (indexed / total) * 100) : 0;
seedProgress.style.width = `${percent}%`; seedProgress.style.width = `${percent}%`;
seedStatus.textContent = `${status.message || "Idle"}${status.source ? ` Source: ${status.source}` : ""}`; seedStatus.textContent = status.message || "Idle";
} catch { } catch {
seedProgress.style.width = "0%"; seedProgress.style.width = "0%";
seedStatus.textContent = "Top-site seed status unavailable."; seedStatus.textContent = "Seed status unavailable.";
} }
} }
@@ -316,13 +315,11 @@
try { try {
const response = await fetch(`${API_BASE}/crawl/top-sites`, { method: "POST" }); const response = await fetch(`${API_BASE}/crawl/top-sites`, { method: "POST" });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) throw new Error(data.detail || "Unable to queue seed.");
throw new Error(data.detail || "Unable to queue top-site seed.");
}
seedStatus.textContent = "Top-site seed queued."; seedStatus.textContent = "Top-site seed queued.";
await refreshSeedStatus(); await refreshSeedStatus();
} catch (error) { } catch (error) {
seedStatus.textContent = error.message || "Unable to queue top-site seed."; seedStatus.textContent = error.message || "Unable to queue seed.";
} finally { } finally {
setTimeout(() => { setTimeout(() => {
seedTopSites.disabled = false; seedTopSites.disabled = false;
@@ -333,66 +330,67 @@
async function handleCrawlerSubmit(event) { async function handleCrawlerSubmit(event) {
event.preventDefault(); event.preventDefault();
const seedUrls = seedUrlsField.value const seedUrls = seedUrlsField.value.split("\n").map((value) => value.trim()).filter(Boolean);
.split("\n")
.map((value) => value.trim())
.filter(Boolean);
if (!seedUrls.length) { if (!seedUrls.length) {
crawlerStatus.textContent = "Add at least one seed URL."; crawlerStatus.textContent = "Add at least one seed URL.";
return; return;
} }
const payload = {
seed_urls: seedUrls,
max_depth: Number.parseInt(crawlDepthField.value, 10) || 0,
max_pages_per_domain: Number.parseInt(maxPagesPerDomainField.value, 10) || 1,
same_domain_only: sameDomainOnlyField.checked,
};
crawlerStatus.textContent = "Starting crawl..."; crawlerStatus.textContent = "Starting crawl...";
try { try {
const response = await fetch(`${API_BASE}/crawl`, { const response = await fetch(`${API_BASE}/crawl`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify({
seed_urls: seedUrls,
max_depth: Number.parseInt(crawlDepthField.value, 10) || 0,
max_pages_per_domain: Number.parseInt(maxPagesPerDomainField.value, 10) || 1,
same_domain_only: sameDomainOnlyField.checked,
}),
}); });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) throw new Error(data.detail || "Unable to start crawler.");
throw new Error(data.detail || "Unable to start the crawler.");
}
crawlerStatus.textContent = `Crawl started for ${seedUrls.length} seed URL${seedUrls.length === 1 ? "" : "s"}.`; crawlerStatus.textContent = `Crawl started for ${seedUrls.length} seed URL${seedUrls.length === 1 ? "" : "s"}.`;
setTimeout(() => { setTimeout(() => {
setModalOpen(false); setModalOpen(false);
refreshStats(); refreshStats();
}, 900); }, 900);
} catch (error) { } catch (error) {
crawlerStatus.textContent = error.message || "Unable to start the crawler."; crawlerStatus.textContent = error.message || "Unable to start crawler.";
} }
} }
searchForm.addEventListener("submit", (event) => { searchForm.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
runSearch("all"); runSearch();
}); });
document.querySelectorAll("[data-search-type]").forEach((button) => { document.querySelectorAll(".mode-btn").forEach((button) => {
button.addEventListener("click", () => runSearch(button.dataset.searchType || "all")); button.addEventListener("click", () => {
setType(button.dataset.searchType || "all");
});
}); });
openCrawlerModal.addEventListener("click", () => setModalOpen(true)); document.querySelectorAll(".query-card").forEach((button) => {
openCrawlerModalSecondary.addEventListener("click", () => setModalOpen(true)); button.addEventListener("click", () => {
searchInput.value = button.dataset.query || "";
searchInput.focus();
});
});
openCrawlerModal?.addEventListener("click", () => setModalOpen(true));
closeCrawlerModal.addEventListener("click", () => setModalOpen(false)); closeCrawlerModal.addEventListener("click", () => setModalOpen(false));
cancelCrawler.addEventListener("click", () => setModalOpen(false)); cancelCrawler.addEventListener("click", () => setModalOpen(false));
crawlerModal.addEventListener("click", (event) => { crawlerModal.addEventListener("click", (event) => {
if (event.target === crawlerModal) { if (event.target === crawlerModal) setModalOpen(false);
setModalOpen(false);
}
}); });
seedTopSites.addEventListener("click", seedTopSitesNow); seedTopSites.addEventListener("click", seedTopSitesNow);
crawlerForm.addEventListener("submit", handleCrawlerSubmit); crawlerForm.addEventListener("submit", handleCrawlerSubmit);
modelSelect.addEventListener("change", () => {
modelHint.textContent = modelSelect.value || "Ready";
});
setType("all");
loadModels();
refreshStats(); refreshStats();
refreshSeedStatus(); refreshSeedStatus();
setInterval(refreshStats, 10000); setInterval(refreshStats, 10000);
+339 -187
View File
@@ -10,147 +10,141 @@
theme: { theme: {
extend: { extend: {
colors: { colors: {
sfetch: { app: {
bg: "#f8fafc", bg: "#f6f8fb",
surface: "#ffffff", surface: "#ffffff",
surfaceSoft: "#f1f5f9", ink: "#111827",
ink: "#202124", muted: "#5f6b7a",
muted: "#5f6368", border: "#d8dee8",
border: "#dadce0", soft: "#eef2f7",
blue: "#1a73e8", primary: "#174ea6",
orange: "#de5833", primaryDark: "#123b7d",
green: "#0b8043", success: "#137333",
warning: "#b06000",
}, },
}, },
boxShadow: { boxShadow: {
search: "0 2px 8px rgba(60, 64, 67, 0.14), 0 1px 3px rgba(60, 64, 67, 0.12)", panel: "0 18px 45px rgba(15, 23, 42, 0.08)",
panel: "0 16px 40px rgba(15, 23, 42, 0.08)", focus: "0 0 0 3px rgba(23, 78, 166, 0.14)",
}, },
}, },
}, },
}; };
</script> </script>
<style> <style>
:root { :root { color-scheme: light; }
color-scheme: light;
}
body { body {
background: #ffffff; background: #ffffff;
color: #202124; color: #111827;
font-family: Arial, Helvetica, sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
} }
.brand {
font-family: Arial, Helvetica, sans-serif;
font-weight: 700;
letter-spacing: 0;
}
.brand span:nth-child(1) { color: #de5833; }
.brand span:nth-child(2) { color: #1a73e8; }
.brand span:nth-child(3) { color: #188038; }
.brand span:nth-child(4) { color: #fbbc04; }
.brand span:nth-child(5) { color: #1a73e8; }
.brand span:nth-child(6) { color: #de5833; }
.skeleton { .skeleton {
background: linear-gradient(90deg, #eef2f7 25%, #f8fafc 37%, #eef2f7 63%); background: linear-gradient(90deg, #eef2f7 25%, #f8fafc 37%, #eef2f7 63%);
background-size: 400% 100%; background-size: 400% 100%;
animation: shimmer 1.4s ease infinite; animation: shimmer 1.4s ease infinite;
} }
mark { mark {
background: rgba(251, 188, 4, 0.28); background: rgba(23, 78, 166, 0.12);
color: #202124; color: #111827;
padding: 0 0.12rem; padding: 0 0.12rem;
border-radius: 0.2rem; border-radius: 0.2rem;
} }
@keyframes shimmer { @keyframes shimmer {
0% { background-position: 100% 50%; } 0% { background-position: 100% 50%; }
100% { background-position: 0 50%; } 100% { background-position: 0 50%; }
} }
@keyframes barrel-roll {
0% { transform: rotateZ(0deg); }
100% { transform: rotateZ(360deg); }
}
.barrel-roll {
animation: barrel-roll 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
</style> </style>
</head> </head>
<body class="min-h-screen"> <body class="min-h-screen">
<div class="min-h-screen"> <header class="sticky top-0 z-20 border-b border-app-border bg-white/95 backdrop-blur">
<header class="sticky top-0 z-20 border-b border-sfetch-border bg-white/95 backdrop-blur"> <div class="mx-auto flex max-w-6xl flex-col gap-4 px-5 py-4 lg:flex-row lg:items-center">
<div class="mx-auto flex max-w-6xl flex-col gap-4 px-5 py-4 sm:flex-row sm:items-center"> <a href="./index.html" class="text-2xl font-semibold tracking-tight text-app-ink">sFetch</a>
<a href="./index.html" class="brand text-3xl leading-none" aria-label="sFetch home">
<span>s</span><span>F</span><span>e</span><span>t</span><span>c</span><span>h</span>
</a>
<form id="searchForm" class="flex flex-1 items-center gap-3"> <form id="searchForm" class="flex flex-1 items-center gap-3">
<label <label
for="searchInput" for="searchInput"
class="flex min-h-12 flex-1 items-center gap-3 rounded-full border border-sfetch-border bg-white px-4 transition focus-within:border-transparent focus-within:shadow-search" class="flex min-h-12 flex-1 items-center gap-3 rounded-lg border border-app-border bg-white px-4 transition focus-within:border-app-primary focus-within:shadow-focus"
>
<svg class="h-5 w-5 shrink-0 text-sfetch-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="11" cy="11" r="6"></circle>
<path d="M20 20L16.65 16.65"></path>
</svg>
<input
id="searchInput"
type="text"
autocomplete="off"
class="w-full bg-transparent text-base text-sfetch-ink outline-none placeholder:text-sfetch-muted"
placeholder="Search sFetch"
/>
</label>
<button
id="searchButton"
type="submit"
class="rounded-md bg-sfetch-blue px-5 py-3 text-sm font-medium text-white transition hover:bg-[#1558b0]"
>
Search
</button>
</form>
<a
href="./index.html"
class="rounded-md border border-sfetch-border bg-white px-4 py-2 text-sm font-medium text-sfetch-ink transition hover:border-sfetch-orange hover:text-sfetch-orange"
> >
Index tools <svg class="h-5 w-5 shrink-0 text-app-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
</a> <circle cx="11" cy="11" r="6"></circle>
</div> <path d="M20 20L16.65 16.65"></path>
<nav class="mx-auto flex max-w-6xl gap-7 px-5 text-sm" aria-label="Search verticals"> </svg>
<button id="tabAll" class="tab-btn border-b-2 border-transparent pb-3 font-medium text-sfetch-muted">All</button> <input
<button id="tabImages" class="tab-btn border-b-2 border-transparent pb-3 font-medium text-sfetch-muted">Images</button> id="searchInput"
<button id="tabVideos" class="tab-btn border-b-2 border-transparent pb-3 font-medium text-sfetch-muted">Videos</button> type="text"
</nav> autocomplete="off"
</header> class="w-full bg-transparent text-base text-app-ink outline-none placeholder:text-app-muted"
placeholder="Search sFetch"
/>
</label>
<button type="submit" class="rounded-md bg-app-primary px-5 py-3 text-sm font-semibold text-white transition hover:bg-app-primaryDark">
Search
</button>
</form>
<main class="mx-auto max-w-6xl px-5 py-8"> <nav class="flex flex-wrap gap-2 text-sm">
<p id="metaText" class="text-sm text-sfetch-muted"></p> <a href="./index.html" class="rounded-md px-3 py-2 font-medium text-app-muted transition hover:bg-app-soft hover:text-app-ink">Search Home</a>
<section id="resultsContainer" class="mt-6"></section> <a href="./ai.html" class="rounded-md px-3 py-2 font-medium text-app-muted transition hover:bg-app-soft hover:text-app-ink">AI Chat</a>
<nav id="pagination" class="mt-10 flex items-center justify-start gap-2" aria-label="Pagination"></nav> </nav>
</main> </div>
</div> <nav class="mx-auto flex max-w-6xl gap-7 px-5 text-sm" aria-label="Search verticals">
<button id="tabAll" class="tab-btn border-b-2 border-transparent pb-3 font-medium text-app-muted">All</button>
<button id="tabImages" class="tab-btn border-b-2 border-transparent pb-3 font-medium text-app-muted">Images</button>
<button id="tabVideos" class="tab-btn border-b-2 border-transparent pb-3 font-medium text-app-muted">Videos</button>
</nav>
</header>
<main class="mx-auto max-w-6xl px-5 py-8">
<p id="metaText" class="text-sm text-app-muted"></p>
<section id="aiPanel" class="mt-5 hidden max-w-4xl rounded-lg border border-app-border bg-app-bg p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-wide text-app-primary">AI answer</p>
<p id="aiStatus" class="mt-1 text-sm text-app-muted">Preparing answer...</p>
</div>
<div class="grid gap-3 sm:grid-cols-[220px_150px_auto] sm:items-end">
<div>
<label for="aiModelSelect" class="mb-1 block text-xs font-medium text-app-muted">Model</label>
<select id="aiModelSelect" class="w-full rounded-md border border-app-border bg-white px-3 py-2 text-sm text-app-ink outline-none focus:border-app-primary focus:shadow-focus">
<option value="">Loading models...</option>
</select>
</div>
<label class="flex items-center gap-2 rounded-md border border-app-border bg-white px-3 py-2 text-sm text-app-ink">
<input id="aiUseWeb" type="checkbox" checked class="h-4 w-4 rounded border-app-border text-app-primary" />
Web context
</label>
<button id="aiRegenerate" class="rounded-md bg-app-ink px-4 py-2 text-sm font-semibold text-white transition hover:bg-black">
Generate
</button>
</div>
</div>
<div id="aiAnswer" class="mt-5 whitespace-pre-wrap text-sm leading-7 text-app-ink"></div>
<details id="aiThinkingWrap" class="mt-4 hidden rounded-md border border-app-border bg-white p-3">
<summary class="cursor-pointer text-sm font-medium text-app-muted">Reasoning trace</summary>
<pre id="aiThinking" class="mt-3 whitespace-pre-wrap text-xs leading-5 text-app-muted"></pre>
</details>
<div id="aiSources" class="mt-5 grid gap-2"></div>
</section>
<section id="resultsContainer" class="mt-6"></section>
<nav id="pagination" class="mt-10 flex items-center justify-start gap-2" aria-label="Pagination"></nav>
</main>
<div id="imageModal" class="fixed inset-0 z-50 hidden bg-slate-950/60"> <div id="imageModal" class="fixed inset-0 z-50 hidden bg-slate-950/60">
<div class="absolute inset-y-0 right-0 w-full max-w-4xl border-l border-sfetch-border bg-white shadow-panel"> <div class="absolute inset-y-0 right-0 w-full max-w-4xl border-l border-app-border bg-white shadow-panel">
<div class="flex items-center justify-between border-b border-sfetch-border px-6 py-4"> <div class="flex items-center justify-between border-b border-app-border px-6 py-4">
<h3 id="modalTitle" class="truncate text-base font-medium text-sfetch-ink">Image preview</h3> <h3 id="modalTitle" class="truncate text-base font-medium text-app-ink">Image preview</h3>
<button id="closeModal" class="flex h-9 w-9 items-center justify-center rounded-full text-sfetch-muted transition hover:bg-sfetch-surfaceSoft hover:text-sfetch-ink"> <button id="closeModal" class="flex h-9 w-9 items-center justify-center rounded-md text-app-muted transition hover:bg-app-soft hover:text-app-ink">
X X
</button> </button>
</div> </div>
<div class="h-[calc(100vh-73px)] overflow-y-auto px-6 py-5"> <div class="h-[calc(100vh-73px)] overflow-y-auto px-6 py-5">
<div class="overflow-hidden rounded-lg bg-sfetch-surfaceSoft"> <div class="overflow-hidden rounded-lg bg-app-soft">
<img id="modalImage" class="max-h-[62vh] w-full object-contain" alt="Preview" /> <img id="modalImage" class="max-h-[62vh] w-full object-contain" alt="Preview" />
</div> </div>
<div class="mt-6"> <div class="mt-6">
<h4 class="mb-3 text-sm font-medium text-sfetch-muted">Related images</h4> <h4 class="mb-3 text-sm font-medium text-app-muted">Related images</h4>
<div id="relatedImages" class="grid grid-cols-2 gap-3 sm:grid-cols-3"></div> <div id="relatedImages" class="grid grid-cols-2 gap-3 sm:grid-cols-3"></div>
</div> </div>
</div> </div>
@@ -169,6 +163,15 @@
const tabAll = document.getElementById("tabAll"); const tabAll = document.getElementById("tabAll");
const tabImages = document.getElementById("tabImages"); const tabImages = document.getElementById("tabImages");
const tabVideos = document.getElementById("tabVideos"); const tabVideos = document.getElementById("tabVideos");
const aiPanel = document.getElementById("aiPanel");
const aiStatus = document.getElementById("aiStatus");
const aiAnswer = document.getElementById("aiAnswer");
const aiSources = document.getElementById("aiSources");
const aiModelSelect = document.getElementById("aiModelSelect");
const aiUseWeb = document.getElementById("aiUseWeb");
const aiRegenerate = document.getElementById("aiRegenerate");
const aiThinkingWrap = document.getElementById("aiThinkingWrap");
const aiThinking = document.getElementById("aiThinking");
const imageModal = document.getElementById("imageModal"); const imageModal = document.getElementById("imageModal");
const closeModalBtn = document.getElementById("closeModal"); const closeModalBtn = document.getElementById("closeModal");
const modalImage = document.getElementById("modalImage"); const modalImage = document.getElementById("modalImage");
@@ -186,29 +189,35 @@
.replaceAll("'", "&#039;"); .replaceAll("'", "&#039;");
} }
function getParams() {
return new URLSearchParams(window.location.search);
}
function getTypeFromUrl() { function getTypeFromUrl() {
const typeValue = new URLSearchParams(window.location.search).get("type"); const typeValue = getParams().get("type");
if (typeValue === "image" || typeValue === "video" || typeValue === "all") { return ["image", "video", "all"].includes(typeValue) ? typeValue : "all";
return typeValue;
}
return "all";
} }
function getQueryFromUrl() { function getQueryFromUrl() {
return (new URLSearchParams(window.location.search).get("q") || "").trim(); return (getParams().get("q") || "").trim();
} }
function getPageFromUrl() { function getPageFromUrl() {
const raw = new URLSearchParams(window.location.search).get("page") || "1"; const page = Number.parseInt(getParams().get("page") || "1", 10);
const page = Number.parseInt(raw, 10);
return Number.isNaN(page) || page < 1 ? 1 : page; return Number.isNaN(page) || page < 1 ? 1 : page;
} }
function updateUrl(query, page) { function updateUrl(query, page) {
const params = new URLSearchParams(window.location.search); const params = getParams();
params.set("q", query); params.set("q", query);
page > 1 ? params.set("page", String(page)) : params.delete("page"); page > 1 ? params.set("page", String(page)) : params.delete("page");
currentType === "all" ? params.delete("type") : params.set("type", currentType); currentType === "all" ? params.delete("type") : params.set("type", currentType);
if (currentType === "all") {
params.set("ai", "1");
if (aiModelSelect.value) {
params.set("model", aiModelSelect.value);
}
}
window.history.replaceState({}, "", `${window.location.pathname}?${params.toString()}`); window.history.replaceState({}, "", `${window.location.pathname}?${params.toString()}`);
} }
@@ -219,13 +228,43 @@
[tabVideos, currentType === "video"], [tabVideos, currentType === "video"],
]; ];
tabs.forEach(([tab, active]) => { tabs.forEach(([tab, active]) => {
tab.classList.toggle("border-sfetch-orange", active); tab.classList.toggle("border-app-primary", active);
tab.classList.toggle("text-sfetch-ink", active); tab.classList.toggle("text-app-ink", active);
tab.classList.toggle("border-transparent", !active); tab.classList.toggle("border-transparent", !active);
tab.classList.toggle("text-sfetch-muted", !active); tab.classList.toggle("text-app-muted", !active);
}); });
} }
async function loadModels() {
try {
const response = await fetch(`${API_BASE}/ai/models`);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.detail || "Unable to load models.");
}
const selectedFromUrl = getParams().get("model");
aiModelSelect.innerHTML = "";
(payload.models || []).forEach((model) => {
const name = model.name || model.model;
if (!name) {
return;
}
const option = document.createElement("option");
option.value = name;
option.textContent = name;
if (name === selectedFromUrl || (!selectedFromUrl && name === payload.default_model)) {
option.selected = true;
}
aiModelSelect.appendChild(option);
});
if (!aiModelSelect.options.length) {
aiModelSelect.innerHTML = `<option value="${payload.default_model || "gpt-oss:120b"}">${payload.default_model || "gpt-oss:120b"}</option>`;
}
} catch {
aiModelSelect.innerHTML = '<option value="gpt-oss:120b">gpt-oss:120b</option>';
}
}
async function fetchSearch(type, query, limit, offset) { async function fetchSearch(type, query, limit, offset) {
const response = await fetch( const response = await fetch(
`${API_BASE}/search?q=${encodeURIComponent(query)}&type=${type}&limit=${limit}&offset=${offset}` `${API_BASE}/search?q=${encodeURIComponent(query)}&type=${type}&limit=${limit}&offset=${offset}`
@@ -237,6 +276,114 @@
return data; return data;
} }
async function streamSSE(url, payload, handlers) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok || !response.body) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || "Model stream failed.");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop() || "";
for (const rawEvent of events) {
const eventName = (rawEvent.match(/^event: (.+)$/m) || [])[1] || "message";
const dataLine = rawEvent
.split("\n")
.filter((line) => line.startsWith("data: "))
.map((line) => line.slice(6))
.join("\n");
const data = dataLine ? JSON.parse(dataLine) : {};
handlers[eventName]?.(data);
}
}
}
async function fetchAIAnswer(query) {
aiPanel.classList.remove("hidden");
aiStatus.textContent = "Streaming answer with Ollama Cloud...";
aiAnswer.textContent = "";
aiSources.innerHTML = "";
aiThinkingWrap.classList.add("hidden");
aiThinking.textContent = "";
aiRegenerate.disabled = true;
try {
let finalContent = "";
await streamSSE(
`${API_BASE}/ai/search/stream`,
{
query,
model: aiModelSelect.value,
include_web: aiUseWeb.checked,
local_result_limit: 5,
web_result_limit: 5,
},
{
meta(data) {
aiStatus.textContent = `Streaming from ${data.model || aiModelSelect.value}`;
renderAISources(data.sources || []);
},
thinking(data) {
aiThinkingWrap.classList.remove("hidden");
aiThinking.textContent += data.delta || "";
},
content(data) {
finalContent += data.delta || "";
aiAnswer.textContent = finalContent;
},
done(data) {
aiStatus.textContent = `Generated by ${data.model || aiModelSelect.value}`;
aiAnswer.textContent = data.content || finalContent || "No answer returned.";
},
error(data) {
throw new Error(data.detail || "Unable to generate AI answer.");
},
}
);
} catch (error) {
aiStatus.textContent = "AI answer unavailable";
aiAnswer.textContent = error.message || "Unable to generate AI answer.";
} finally {
aiRegenerate.disabled = false;
}
}
function renderAISources(sources) {
aiSources.innerHTML = "";
if (!sources.length) {
return;
}
const heading = document.createElement("p");
heading.className = "text-xs font-semibold uppercase tracking-wide text-app-muted";
heading.textContent = "Sources";
aiSources.appendChild(heading);
sources.slice(0, 8).forEach((source, index) => {
const link = document.createElement("a");
link.href = source.url;
link.target = "_blank";
link.rel = "noreferrer noopener";
link.className = "block rounded-md border border-app-border bg-white p-3 text-sm transition hover:border-app-primary";
link.innerHTML = `
<span class="font-semibold text-app-ink">[${index + 1}] ${escapeHTML(source.title)}</span>
<span class="ml-2 rounded bg-app-soft px-2 py-0.5 text-xs uppercase text-app-muted">${escapeHTML(source.source_type)}</span>
<span class="mt-1 block truncate text-xs text-app-muted">${escapeHTML(source.url)}</span>
`;
aiSources.appendChild(link);
});
}
function extractHost(url) { function extractHost(url) {
try { try {
return new URL(url).hostname.replace(/^www\./, ""); return new URL(url).hostname.replace(/^www\./, "");
@@ -272,11 +419,12 @@
function renderError(message) { function renderError(message) {
metaText.textContent = "Search unavailable"; metaText.textContent = "Search unavailable";
aiPanel.classList.add("hidden");
resultsContainer.className = "mt-6"; resultsContainer.className = "mt-6";
resultsContainer.innerHTML = ` resultsContainer.innerHTML = `
<section class="max-w-2xl rounded-lg border border-sfetch-border bg-sfetch-bg px-5 py-6"> <section class="max-w-2xl rounded-lg border border-app-border bg-app-bg px-5 py-6">
<p class="text-lg text-sfetch-ink">Unable to load results.</p> <p class="text-lg text-app-ink">Unable to load results.</p>
<p class="mt-2 text-sm text-sfetch-muted">${escapeHTML(message)}</p> <p class="mt-2 text-sm text-app-muted">${escapeHTML(message)}</p>
</section> </section>
`; `;
paginationNav.innerHTML = ""; paginationNav.innerHTML = "";
@@ -286,10 +434,9 @@
metaText.textContent = "About 0 results"; metaText.textContent = "About 0 results";
resultsContainer.className = "mt-6"; resultsContainer.className = "mt-6";
resultsContainer.innerHTML = ` resultsContainer.innerHTML = `
<section class="max-w-2xl rounded-lg border border-sfetch-border bg-sfetch-bg px-5 py-8"> <section class="max-w-2xl rounded-lg border border-app-border bg-app-bg px-5 py-8">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-sfetch-surfaceSoft text-lg font-bold text-sfetch-orange">s</div> <h2 class="text-xl font-semibold text-app-ink">No results found</h2>
<h2 class="mt-4 text-xl text-sfetch-ink">No results found</h2> <p class="mt-2 text-sm text-app-muted">No indexed pages matched "${escapeHTML(query)}".</p>
<p class="mt-2 text-sm text-sfetch-muted">No indexed pages matched "${escapeHTML(query)}".</p>
</section> </section>
`; `;
paginationNav.innerHTML = ""; paginationNav.innerHTML = "";
@@ -310,10 +457,10 @@
btn.disabled = disabled; btn.disabled = disabled;
btn.className = `flex h-10 min-w-10 items-center justify-center rounded-md border px-3 text-sm transition ${ btn.className = `flex h-10 min-w-10 items-center justify-center rounded-md border px-3 text-sm transition ${
active active
? "border-sfetch-blue bg-sfetch-blue text-white" ? "border-app-primary bg-app-primary text-white"
: disabled : disabled
? "cursor-not-allowed border-sfetch-border text-sfetch-muted/50" ? "cursor-not-allowed border-app-border text-app-muted/50"
: "border-sfetch-border text-sfetch-ink hover:border-sfetch-blue hover:text-sfetch-blue" : "border-app-border text-app-ink hover:border-app-primary hover:text-app-primary"
}`; }`;
if (!disabled && !active) { if (!disabled && !active) {
btn.addEventListener("click", () => runSearch(query, page)); btn.addEventListener("click", () => runSearch(query, page));
@@ -322,18 +469,15 @@
}; };
paginationNav.appendChild(button("<", currentPage - 1, currentPage === 1)); paginationNav.appendChild(button("<", currentPage - 1, currentPage === 1));
const maxVisiblePages = 5; const maxVisiblePages = 5;
let start = Math.max(1, currentPage - 2); let start = Math.max(1, currentPage - 2);
let end = Math.min(totalPages, start + maxVisiblePages - 1); let end = Math.min(totalPages, start + maxVisiblePages - 1);
if (end - start < maxVisiblePages - 1) { if (end - start < maxVisiblePages - 1) {
start = Math.max(1, end - maxVisiblePages + 1); start = Math.max(1, end - maxVisiblePages + 1);
} }
for (let i = start; i <= end; i += 1) { for (let i = start; i <= end; i += 1) {
paginationNav.appendChild(button(String(i), i, false, i === currentPage)); paginationNav.appendChild(button(String(i), i, false, i === currentPage));
} }
paginationNav.appendChild(button(">", currentPage + 1, currentPage === totalPages)); paginationNav.appendChild(button(">", currentPage + 1, currentPage === totalPages));
} }
@@ -348,14 +492,9 @@
.slice(0, 8) .slice(0, 8)
.forEach((item) => { .forEach((item) => {
const thumb = document.createElement("button"); const thumb = document.createElement("button");
thumb.className = "overflow-hidden rounded-md border border-sfetch-border transition hover:border-sfetch-orange"; thumb.className = "overflow-hidden rounded-md border border-app-border transition hover:border-app-primary";
thumb.innerHTML = ` thumb.innerHTML = `
<img <img src="${escapeHTML(item.url)}" alt="${escapeHTML(item.alt_text || "Related image")}" class="h-24 w-full object-cover" loading="lazy" />
src="${escapeHTML(item.url)}"
alt="${escapeHTML(item.alt_text || "Related image")}"
class="h-24 w-full object-cover"
loading="lazy"
/>
`; `;
thumb.addEventListener("click", () => { thumb.addEventListener("click", () => {
const realIndex = relatedPool.findIndex((candidate) => candidate.id === item.id); const realIndex = relatedPool.findIndex((candidate) => candidate.id === item.id);
@@ -372,22 +511,18 @@
} }
function renderImageGrid(results) { function renderImageGrid(results) {
aiPanel.classList.add("hidden");
resultsContainer.className = "mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4"; resultsContainer.className = "mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4";
resultsContainer.innerHTML = ""; resultsContainer.innerHTML = "";
results.forEach((result, index) => { results.forEach((result, index) => {
const card = document.createElement("article"); const card = document.createElement("article");
card.className = "group cursor-pointer overflow-hidden rounded-lg border border-sfetch-border bg-white transition hover:border-sfetch-orange"; card.className = "group cursor-pointer overflow-hidden rounded-lg border border-app-border bg-white transition hover:border-app-primary";
card.innerHTML = ` card.innerHTML = `
<div class="aspect-square overflow-hidden bg-sfetch-surfaceSoft"> <div class="aspect-square overflow-hidden bg-app-soft">
<img <img src="${escapeHTML(result.url)}" alt="${escapeHTML(result.alt_text || "Image result")}" class="h-full w-full object-cover transition duration-200 group-hover:scale-105" loading="lazy" />
src="${escapeHTML(result.url)}"
alt="${escapeHTML(result.alt_text || "Image result")}"
class="h-full w-full object-cover transition duration-200 group-hover:scale-105"
loading="lazy"
/>
</div> </div>
<div class="truncate px-3 py-2 text-xs text-sfetch-muted">${escapeHTML(result.alt_text || extractHost(result.page_url))}</div> <div class="truncate px-3 py-2 text-xs text-app-muted">${escapeHTML(result.alt_text || extractHost(result.page_url))}</div>
`; `;
card.addEventListener("click", () => openImageModal(result, index, results)); card.addEventListener("click", () => openImageModal(result, index, results));
resultsContainer.appendChild(card); resultsContainer.appendChild(card);
@@ -395,26 +530,27 @@
} }
function renderVideoCards(results) { function renderVideoCards(results) {
resultsContainer.className = "mt-6 space-y-4"; aiPanel.classList.add("hidden");
resultsContainer.className = "mt-6 max-w-3xl space-y-4";
resultsContainer.innerHTML = ""; resultsContainer.innerHTML = "";
results.forEach((result) => { results.forEach((result) => {
const thumbnail = videoThumbnail(result.url); const thumbnail = videoThumbnail(result.url);
const card = document.createElement("article"); const card = document.createElement("article");
card.className = "overflow-hidden rounded-lg border border-sfetch-border bg-white"; card.className = "overflow-hidden rounded-lg border border-app-border bg-white";
card.innerHTML = ` card.innerHTML = `
<a href="${escapeHTML(result.url)}" target="_blank" rel="noreferrer noopener" class="block md:flex"> <a href="${escapeHTML(result.url)}" target="_blank" rel="noreferrer noopener" class="block md:flex">
<div class="relative h-44 w-full shrink-0 overflow-hidden bg-sfetch-surfaceSoft md:w-72"> <div class="relative h-44 w-full shrink-0 overflow-hidden bg-app-soft md:w-72">
${ ${
thumbnail thumbnail
? `<img src="${escapeHTML(thumbnail)}" alt="${escapeHTML(result.title)}" class="h-full w-full object-cover" loading="lazy" />` ? `<img src="${escapeHTML(thumbnail)}" alt="${escapeHTML(result.title)}" class="h-full w-full object-cover" loading="lazy" />`
: `<div class="flex h-full items-center justify-center text-sfetch-muted">Video</div>` : `<div class="flex h-full items-center justify-center text-app-muted">Video</div>`
} }
</div> </div>
<div class="space-y-2 p-5"> <div class="space-y-2 p-5">
<p class="text-xs uppercase text-sfetch-green">${escapeHTML(extractHost(result.url))}</p> <p class="text-xs uppercase text-app-success">${escapeHTML(extractHost(result.url))}</p>
<h3 class="text-xl font-medium text-sfetch-blue">${escapeHTML(result.title)}</h3> <h3 class="text-xl font-medium text-app-primary">${escapeHTML(result.title)}</h3>
<p class="text-sm text-sfetch-muted">Source: ${escapeHTML(extractHost(result.page_url))}</p> <p class="text-sm text-app-muted">Source: ${escapeHTML(extractHost(result.page_url))}</p>
</div> </div>
</a> </a>
`; `;
@@ -431,20 +567,15 @@
const host = extractHost(result.url); const host = extractHost(result.url);
article.className = "space-y-1"; article.className = "space-y-1";
article.innerHTML = ` article.innerHTML = `
<div class="flex items-center gap-2 text-sm text-sfetch-muted"> <div class="flex items-center gap-2 text-sm text-app-muted">
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-sfetch-surfaceSoft text-xs font-bold text-sfetch-orange">${escapeHTML(host.slice(0, 1).toUpperCase())}</div> <div class="flex h-7 w-7 shrink-0 items-center justify-center rounded bg-app-soft text-xs font-bold text-app-primary">${escapeHTML(host.slice(0, 1).toUpperCase())}</div>
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sfetch-ink">${escapeHTML(host)}</p> <p class="text-app-ink">${escapeHTML(host)}</p>
<p class="truncate text-xs">${escapeHTML(result.url)}</p> <p class="truncate text-xs">${escapeHTML(result.url)}</p>
</div> </div>
</div> </div>
<a <a href="${escapeHTML(result.url)}" target="_blank" rel="noreferrer noopener" class="block text-xl leading-tight text-app-primary hover:underline">${escapeHTML(result.title)}</a>
href="${escapeHTML(result.url)}" <p class="text-sm leading-6 text-app-muted">${result.snippet}</p>
target="_blank"
rel="noreferrer noopener"
class="block text-xl leading-tight text-sfetch-blue hover:underline"
>${escapeHTML(result.title)}</a>
<p class="text-sm leading-6 text-sfetch-muted">${result.snippet}</p>
`; `;
wrapper.appendChild(article); wrapper.appendChild(article);
}); });
@@ -453,10 +584,12 @@
} }
function renderAllMode(webData, imageData, videoData, page) { function renderAllMode(webData, imageData, videoData, page) {
aiPanel.classList.remove("hidden");
const start = (page - 1) * RESULTS_PER_PAGE + 1; const start = (page - 1) * RESULTS_PER_PAGE + 1;
const end = Math.min(start + webData.results.length - 1, webData.total); const end = Math.min(start + webData.results.length - 1, webData.total);
if (webData.total === 0 && imageData.total === 0 && videoData.total === 0) { if (webData.total === 0 && imageData.total === 0 && videoData.total === 0) {
renderEmpty(webData.query); renderEmpty(webData.query);
fetchAIAnswer(webData.query);
return; return;
} }
@@ -464,22 +597,26 @@
? `${start}-${end} of about ${webData.total} web results` ? `${start}-${end} of about ${webData.total} web results`
: "No direct web matches, showing media results"; : "No direct web matches, showing media results";
resultsContainer.className = "mt-6 space-y-9"; resultsContainer.className = "mt-8 space-y-9";
resultsContainer.innerHTML = ""; resultsContainer.innerHTML = "";
if (webData.results.length) {
resultsContainer.appendChild(renderWebList(webData.results));
}
if (imageData.results.length) { if (imageData.results.length) {
const imageSection = document.createElement("section"); const imageSection = document.createElement("section");
imageSection.innerHTML = ` imageSection.innerHTML = `
<div class="mb-3 flex max-w-3xl items-center justify-between"> <div class="mb-3 flex max-w-3xl items-center justify-between">
<h2 class="text-sm font-semibold text-sfetch-ink">Images</h2> <h2 class="text-sm font-semibold text-app-ink">Images</h2>
<button id="seeAllImagesBtn" class="text-sm font-medium text-sfetch-blue hover:underline">See all</button> <button id="seeAllImagesBtn" class="text-sm font-medium text-app-primary hover:underline">See all</button>
</div> </div>
`; `;
const grid = document.createElement("div"); const grid = document.createElement("div");
grid.className = "grid max-w-3xl grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6"; grid.className = "grid max-w-3xl grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6";
imageData.results.slice(0, 6).forEach((result, index) => { imageData.results.slice(0, 6).forEach((result, index) => {
const button = document.createElement("button"); const button = document.createElement("button");
button.className = "overflow-hidden rounded-md border border-sfetch-border bg-sfetch-surfaceSoft"; button.className = "overflow-hidden rounded-md border border-app-border bg-app-soft";
button.innerHTML = `<img src="${escapeHTML(result.url)}" alt="${escapeHTML(result.alt_text || "Image result")}" class="aspect-square w-full object-cover" loading="lazy" />`; button.innerHTML = `<img src="${escapeHTML(result.url)}" alt="${escapeHTML(result.alt_text || "Image result")}" class="aspect-square w-full object-cover" loading="lazy" />`;
button.addEventListener("click", () => openImageModal(result, index, imageData.results)); button.addEventListener("click", () => openImageModal(result, index, imageData.results));
grid.appendChild(button); grid.appendChild(button);
@@ -492,16 +629,12 @@
}); });
} }
if (webData.results.length) {
resultsContainer.appendChild(renderWebList(webData.results));
}
if (videoData.results.length) { if (videoData.results.length) {
const videoSection = document.createElement("section"); const videoSection = document.createElement("section");
videoSection.innerHTML = ` videoSection.innerHTML = `
<div class="mb-3 flex max-w-3xl items-center justify-between"> <div class="mb-3 flex max-w-3xl items-center justify-between">
<h2 class="text-sm font-semibold text-sfetch-ink">Videos</h2> <h2 class="text-sm font-semibold text-app-ink">Videos</h2>
<button id="seeAllVideosBtn" class="text-sm font-medium text-sfetch-blue hover:underline">See all</button> <button id="seeAllVideosBtn" class="text-sm font-medium text-app-primary hover:underline">See all</button>
</div> </div>
`; `;
const list = document.createElement("div"); const list = document.createElement("div");
@@ -512,19 +645,19 @@
card.href = result.url; card.href = result.url;
card.target = "_blank"; card.target = "_blank";
card.rel = "noreferrer noopener"; card.rel = "noreferrer noopener";
card.className = "block overflow-hidden rounded-lg border border-sfetch-border bg-white transition hover:border-sfetch-orange sm:flex"; card.className = "block overflow-hidden rounded-lg border border-app-border bg-white transition hover:border-app-primary sm:flex";
card.innerHTML = ` card.innerHTML = `
<div class="h-36 w-full shrink-0 overflow-hidden bg-sfetch-surfaceSoft sm:w-56"> <div class="h-36 w-full shrink-0 overflow-hidden bg-app-soft sm:w-56">
${ ${
thumb thumb
? `<img src="${escapeHTML(thumb)}" alt="${escapeHTML(result.title)}" class="h-full w-full object-cover" loading="lazy" />` ? `<img src="${escapeHTML(thumb)}" alt="${escapeHTML(result.title)}" class="h-full w-full object-cover" loading="lazy" />`
: `<div class="flex h-full items-center justify-center text-sfetch-muted">Video</div>` : `<div class="flex h-full items-center justify-center text-app-muted">Video</div>`
} }
</div> </div>
<div class="space-y-2 p-4"> <div class="space-y-2 p-4">
<p class="text-xs uppercase text-sfetch-green">${escapeHTML(extractHost(result.url))}</p> <p class="text-xs uppercase text-app-success">${escapeHTML(extractHost(result.url))}</p>
<h3 class="text-lg font-medium text-sfetch-blue">${escapeHTML(result.title)}</h3> <h3 class="text-lg font-medium text-app-primary">${escapeHTML(result.title)}</h3>
<p class="text-sm text-sfetch-muted">${escapeHTML(extractHost(result.page_url))}</p> <p class="text-sm text-app-muted">${escapeHTML(extractHost(result.page_url))}</p>
</div> </div>
`; `;
list.appendChild(card); list.appendChild(card);
@@ -538,6 +671,7 @@
} }
renderPagination(webData.total, page, webData.query); renderPagination(webData.total, page, webData.query);
fetchAIAnswer(webData.query);
} }
function renderVerticalMode(data, page) { function renderVerticalMode(data, page) {
@@ -566,15 +700,13 @@
function renderLoadingSkeleton() { function renderLoadingSkeleton() {
if (currentType === "image") { if (currentType === "image") {
resultsContainer.className = "mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4"; resultsContainer.className = "mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4";
resultsContainer.innerHTML = Array.from({ length: 8 }) resultsContainer.innerHTML = Array.from({ length: 8 }).map(() => '<div class="skeleton aspect-square rounded-lg"></div>').join("");
.map(() => '<div class="skeleton aspect-square rounded-lg"></div>')
.join("");
metaText.textContent = "Searching images..."; metaText.textContent = "Searching images...";
} else if (currentType === "video") { } else if (currentType === "video") {
resultsContainer.className = "mt-6 max-w-3xl space-y-4"; resultsContainer.className = "mt-6 max-w-3xl space-y-4";
resultsContainer.innerHTML = Array.from({ length: 4 }) resultsContainer.innerHTML = Array.from({ length: 4 })
.map(() => ` .map(() => `
<div class="overflow-hidden rounded-lg border border-sfetch-border bg-white"> <div class="overflow-hidden rounded-lg border border-app-border bg-white">
<div class="skeleton h-36 w-full"></div> <div class="skeleton h-36 w-full"></div>
<div class="space-y-3 p-4"> <div class="space-y-3 p-4">
<div class="skeleton h-3 w-24 rounded-full"></div> <div class="skeleton h-3 w-24 rounded-full"></div>
@@ -586,7 +718,11 @@
.join(""); .join("");
metaText.textContent = "Searching videos..."; metaText.textContent = "Searching videos...";
} else { } else {
resultsContainer.className = "mt-6 max-w-3xl space-y-6"; aiPanel.classList.remove("hidden");
aiStatus.textContent = "Waiting for search results...";
aiAnswer.textContent = "";
aiSources.innerHTML = "";
resultsContainer.className = "mt-8 max-w-3xl space-y-6";
resultsContainer.innerHTML = Array.from({ length: 4 }) resultsContainer.innerHTML = Array.from({ length: 4 })
.map(() => ` .map(() => `
<article class="space-y-3"> <article class="space-y-3">
@@ -606,10 +742,11 @@
async function runSearch(query, page = 1) { async function runSearch(query, page = 1) {
const normalizedQuery = query.trim(); const normalizedQuery = query.trim();
if (!normalizedQuery) { if (!normalizedQuery) {
aiPanel.classList.add("hidden");
metaText.textContent = "Enter a search query."; metaText.textContent = "Enter a search query.";
resultsContainer.className = "mt-6"; resultsContainer.className = "mt-6";
resultsContainer.innerHTML = ` resultsContainer.innerHTML = `
<section class="max-w-2xl rounded-lg border border-sfetch-border bg-sfetch-bg px-5 py-6 text-sm text-sfetch-muted"> <section class="max-w-2xl rounded-lg border border-app-border bg-app-bg px-5 py-6 text-sm text-app-muted">
Type a query above and press Search. Type a query above and press Search.
</section> </section>
`; `;
@@ -617,11 +754,6 @@
return; return;
} }
if (normalizedQuery.toLowerCase() === "do a barrel roll") {
document.documentElement.classList.add("barrel-roll");
setTimeout(() => document.documentElement.classList.remove("barrel-roll"), 1200);
}
updateTabsUI(); updateTabsUI();
updateUrl(normalizedQuery, page); updateUrl(normalizedQuery, page);
searchInput.value = normalizedQuery; searchInput.value = normalizedQuery;
@@ -671,9 +803,24 @@
searchForm.addEventListener("submit", (event) => { searchForm.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
currentType = "all";
runSearch(searchInput.value, 1); runSearch(searchInput.value, 1);
}); });
aiRegenerate.addEventListener("click", () => {
const query = searchInput.value || getQueryFromUrl();
if (query.trim()) {
fetchAIAnswer(query.trim());
}
});
aiModelSelect.addEventListener("change", () => {
const query = searchInput.value || getQueryFromUrl();
if (currentType === "all" && query.trim()) {
updateUrl(query.trim(), getPageFromUrl());
}
});
closeModalBtn.addEventListener("click", closeImageModal); closeModalBtn.addEventListener("click", closeImageModal);
imageModal.addEventListener("click", (event) => { imageModal.addEventListener("click", (event) => {
if (event.target === imageModal) { if (event.target === imageModal) {
@@ -686,8 +833,13 @@
} }
}); });
currentType = getTypeFromUrl(); async function init() {
runSearch(getQueryFromUrl(), getPageFromUrl()); await loadModels();
currentType = getTypeFromUrl();
runSearch(getQueryFromUrl(), getPageFromUrl());
}
init();
</script> </script>
</body> </body>
</html> </html>