401 lines
20 KiB
HTML
401 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>sFetch</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;
|
|
}
|
|
.modal-open { overflow: hidden; }
|
|
</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="aiConfigText" 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 bg-shell-soft px-3 py-2 font-medium text-shell-ink">
|
|
AI Search
|
|
<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>
|
|
|
|
<div class="mt-auto border-t border-shell-line p-4">
|
|
<section class="rounded-2xl border border-shell-line bg-white p-4">
|
|
<p class="text-xs font-semibold uppercase tracking-wide text-shell-muted">Index</p>
|
|
<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">
|
|
<div id="seedProgress" class="h-full w-0 bg-shell-accent transition-all duration-300"></div>
|
|
</div>
|
|
<p id="seedStatus" class="mt-3 text-xs leading-5 text-shell-muted">Seed status unavailable.</p>
|
|
<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">
|
|
Seed top 1000
|
|
</button>
|
|
</section>
|
|
</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="./ai.html" class="rounded-lg border border-shell-line px-3 py-2 text-sm font-medium text-shell-muted">AI Chat</a>
|
|
</div>
|
|
|
|
<div class="grid gap-3 md:grid-cols-[minmax(220px,360px)_150px] 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>
|
|
<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="includeAI" type="checkbox" checked class="h-4 w-4 rounded border-shell-line text-shell-accent" />
|
|
AI answer
|
|
</label>
|
|
</div>
|
|
|
|
<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 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">
|
|
Compare sources
|
|
</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">
|
|
Summarize Python sources
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<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">
|
|
<div class="w-full max-w-xl rounded-2xl border border-shell-line bg-white p-5 shadow-lift">
|
|
<div class="flex items-center justify-between gap-4 border-b border-shell-line pb-4">
|
|
<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">
|
|
X
|
|
</button>
|
|
</div>
|
|
|
|
<form id="crawlerForm" class="mt-5 space-y-4">
|
|
<div>
|
|
<label for="seedUrls" class="mb-2 block text-sm font-medium text-shell-ink">Seed URLs</label>
|
|
<textarea id="seedUrls" rows="6" placeholder="https://example.com 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>
|
|
</div>
|
|
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label for="crawlDepth" class="mb-2 block text-sm font-medium text-shell-ink">Max depth</label>
|
|
<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" />
|
|
</div>
|
|
<div>
|
|
<label for="maxPagesPerDomain" class="mb-2 block text-sm font-medium text-shell-ink">Pages per domain</label>
|
|
<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" />
|
|
</div>
|
|
</div>
|
|
|
|
<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-shell-line text-shell-accent" />
|
|
Same domain only
|
|
</label>
|
|
|
|
<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">
|
|
<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">
|
|
Cancel
|
|
</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">
|
|
Launch crawl
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API_BASE = "http://localhost:8000";
|
|
|
|
const searchForm = document.getElementById("searchForm");
|
|
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 closeCrawlerModal = document.getElementById("closeCrawlerModal");
|
|
const cancelCrawler = document.getElementById("cancelCrawler");
|
|
const crawlerModal = document.getElementById("crawlerModal");
|
|
const crawlerForm = document.getElementById("crawlerForm");
|
|
const crawlerStatus = document.getElementById("crawlerStatus");
|
|
const seedUrlsField = document.getElementById("seedUrls");
|
|
const crawlDepthField = document.getElementById("crawlDepth");
|
|
const maxPagesPerDomainField = document.getElementById("maxPagesPerDomain");
|
|
const sameDomainOnlyField = document.getElementById("sameDomainOnly");
|
|
const statsSummary = document.getElementById("statsSummary");
|
|
const seedStatus = document.getElementById("seedStatus");
|
|
const seedProgress = document.getElementById("seedProgress");
|
|
const seedTopSites = document.getElementById("seedTopSites");
|
|
|
|
let selectedType = "all";
|
|
|
|
function runSearch(type = selectedType) {
|
|
const query = searchInput.value.trim();
|
|
if (!query) {
|
|
searchInput.focus();
|
|
return;
|
|
}
|
|
const params = new URLSearchParams({ q: query });
|
|
if (type !== "all") 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()}`;
|
|
}
|
|
|
|
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) {
|
|
crawlerModal.classList.toggle("opacity-0", !isOpen);
|
|
crawlerModal.classList.toggle("pointer-events-none", !isOpen);
|
|
crawlerModal.setAttribute("aria-hidden", String(!isOpen));
|
|
document.body.classList.toggle("modal-open", isOpen);
|
|
if (isOpen) seedUrlsField.focus();
|
|
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";
|
|
}
|
|
}
|
|
|
|
async function refreshStats() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/stats`);
|
|
const stats = await response.json();
|
|
if (!response.ok) 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}.`;
|
|
} catch {
|
|
statsSummary.textContent = "Backend unavailable";
|
|
}
|
|
}
|
|
|
|
async function refreshSeedStatus() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/crawl/top-sites/status`);
|
|
const status = await response.json();
|
|
if (!response.ok) throw new Error();
|
|
const total = Number(status.total || 0);
|
|
const indexed = Number(status.indexed || 0);
|
|
const percent = total > 0 && status.state === "complete" ? 100 : total > 0 ? Math.min(96, (indexed / total) * 100) : 0;
|
|
seedProgress.style.width = `${percent}%`;
|
|
seedStatus.textContent = status.message || "Idle";
|
|
} catch {
|
|
seedProgress.style.width = "0%";
|
|
seedStatus.textContent = "Seed status unavailable.";
|
|
}
|
|
}
|
|
|
|
async function seedTopSitesNow() {
|
|
seedTopSites.disabled = true;
|
|
seedTopSites.textContent = "Queued";
|
|
try {
|
|
const response = await fetch(`${API_BASE}/crawl/top-sites`, { method: "POST" });
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(data.detail || "Unable to queue seed.");
|
|
seedStatus.textContent = "Top-site seed queued.";
|
|
await refreshSeedStatus();
|
|
} catch (error) {
|
|
seedStatus.textContent = error.message || "Unable to queue seed.";
|
|
} finally {
|
|
setTimeout(() => {
|
|
seedTopSites.disabled = false;
|
|
seedTopSites.textContent = "Seed top 1000";
|
|
}, 1200);
|
|
}
|
|
}
|
|
|
|
async function handleCrawlerSubmit(event) {
|
|
event.preventDefault();
|
|
const seedUrls = seedUrlsField.value.split("\n").map((value) => value.trim()).filter(Boolean);
|
|
if (!seedUrls.length) {
|
|
crawlerStatus.textContent = "Add at least one seed URL.";
|
|
return;
|
|
}
|
|
crawlerStatus.textContent = "Starting crawl...";
|
|
try {
|
|
const response = await fetch(`${API_BASE}/crawl`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
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(() => ({}));
|
|
if (!response.ok) throw new Error(data.detail || "Unable to start crawler.");
|
|
crawlerStatus.textContent = `Crawl started for ${seedUrls.length} seed URL${seedUrls.length === 1 ? "" : "s"}.`;
|
|
setTimeout(() => {
|
|
setModalOpen(false);
|
|
refreshStats();
|
|
}, 900);
|
|
} catch (error) {
|
|
crawlerStatus.textContent = error.message || "Unable to start crawler.";
|
|
}
|
|
}
|
|
|
|
searchForm.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
runSearch();
|
|
});
|
|
|
|
document.querySelectorAll(".mode-btn").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
setType(button.dataset.searchType || "all");
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll(".query-card").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
searchInput.value = button.dataset.query || "";
|
|
searchInput.focus();
|
|
});
|
|
});
|
|
|
|
openCrawlerModal?.addEventListener("click", () => setModalOpen(true));
|
|
closeCrawlerModal.addEventListener("click", () => setModalOpen(false));
|
|
cancelCrawler.addEventListener("click", () => setModalOpen(false));
|
|
crawlerModal.addEventListener("click", (event) => {
|
|
if (event.target === crawlerModal) setModalOpen(false);
|
|
});
|
|
seedTopSites.addEventListener("click", seedTopSitesNow);
|
|
crawlerForm.addEventListener("submit", handleCrawlerSubmit);
|
|
modelSelect.addEventListener("change", () => {
|
|
modelHint.textContent = modelSelect.value || "Ready";
|
|
});
|
|
|
|
setType("all");
|
|
loadModels();
|
|
refreshStats();
|
|
refreshSeedStatus();
|
|
setInterval(refreshStats, 10000);
|
|
setInterval(refreshSeedStatus, 5000);
|
|
</script>
|
|
</body>
|
|
</html>
|