Add AI functionality; fuck up UI royally, still a piece of shit.
This commit is contained in:
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user