import { PreviewServer, ViteDevServer, defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import basicSSL from "@vitejs/plugin-basic-ssl"; import fetch from "node-fetch"; import { RateLimiterMemory } from "rate-limiter-flexible"; import { writeFileSync, readFileSync, existsSync } from "node:fs"; import temporaryDirectory from "temp-dir"; import path from "node:path"; import { initModel, distance as calculateSimilarity, EmbeddingsModel, } from "@energetic-ai/embeddings"; import { modelSource as embeddingModel } from "@energetic-ai/model-embeddings-en"; const serverStartTime = new Date().getTime(); let searchesSinceLastRestart = 0; export default defineConfig(({ command }) => { if (command === "build") regenerateSearchToken(); return { root: "./client", define: { VITE_SEARCH_TOKEN: JSON.stringify(getSearchToken()), }, server: { host: process.env.HOST, port: process.env.PORT ? Number(process.env.PORT) : undefined, hmr: { port: process.env.HMR_PORT ? Number(process.env.HMR_PORT) : undefined, }, }, preview: { host: process.env.HOST, port: process.env.PORT ? Number(process.env.PORT) : undefined, }, build: { target: "esnext", }, plugins: [ process.env.BASIC_SSL === "true" ? basicSSL() : undefined, react(), { name: "configure-server-cross-origin-isolation", configureServer: crossOriginServerHook, configurePreviewServer: crossOriginServerHook, }, { name: "configure-server-search-endpoint", configureServer: searchEndpointServerHook, configurePreviewServer: searchEndpointServerHook, }, { name: "configure-server-status-endpoint", configureServer: statusEndpointServerHook, configurePreviewServer: statusEndpointServerHook, }, { name: "configure-server-cache", configurePreviewServer: cacheServerHook, }, ], }; }); function crossOriginServerHook( server: T, ) { server.middlewares.use((_, response, next) => { /** Server headers for cross origin isolation, which enable clients to use `SharedArrayBuffer` on the Browser. */ const crossOriginIsolationHeaders: { key: string; value: string }[] = [ { key: "Cross-Origin-Embedder-Policy", value: "require-corp", }, { key: "Cross-Origin-Opener-Policy", value: "same-origin", }, { key: "Cross-Origin-Resource-Policy", value: "cross-origin", }, ]; crossOriginIsolationHeaders.forEach(({ key, value }) => { response.setHeader(key, value); }); next(); }); } function statusEndpointServerHook( server: T, ) { server.middlewares.use(async (request, response, next) => { if (!request.url.startsWith("/status")) return next(); const secondsSinceLastRestart = Math.floor( (new Date().getTime() - serverStartTime) / 1000, ); response.end( JSON.stringify({ secondsSinceLastRestart, searchesSinceLastRestart, searchesPerSecond: searchesSinceLastRestart / secondsSinceLastRestart, }), ); }); } function searchEndpointServerHook( server: T, ) { const rateLimiter = new RateLimiterMemory({ points: 2, duration: 10, }); server.middlewares.use(async (request, response, next) => { if (!request.url.startsWith("/search")) return next(); const { searchParams } = new URL( request.url, `http://${request.headers.host}`, ); const token = searchParams.get("token"); if (!token || token !== getSearchToken()) { response.statusCode = 401; response.end("Unauthorized."); return; } const query = searchParams.get("q"); if (!query) { response.statusCode = 400; response.end("Missing the query parameter."); return; } const limitParam = searchParams.get("limit"); const limit = limitParam && Number(limitParam) > 0 ? Number(limitParam) : undefined; try { const remoteAddress = ( (request.headers["x-forwarded-for"] as string) || request.socket.remoteAddress || "unknown" ) .split(",")[0] .trim(); await rateLimiter.consume(remoteAddress); } catch (error) { response.statusCode = 429; response.end("Too many requests."); return; } const searchResults = await fetchSearXNG(query, limit); searchesSinceLastRestart++; if (searchResults.length === 0) { response.end(JSON.stringify([])); return; } try { response.end( JSON.stringify(await rankSearchResults(query, searchResults)), ); } catch (error) { console.error("Error ranking search results:", error); response.end(JSON.stringify(searchResults)); } }); } function cacheServerHook(server: T) { server.middlewares.use(async (request, response, next) => { if ( request.url === "/" || request.url.startsWith("/?") || request.url.endsWith(".html") ) { response.setHeader("Cache-Control", "no-cache"); } else { response.setHeader("Cache-Control", "public, max-age=86400"); } next(); }); } async function fetchSearXNG(query: string, limit?: number) { try { const url = new URL("http://127.0.0.1:8080/search"); url.search = new URLSearchParams({ q: query, language: "auto", safesearch: "0", format: "json", }).toString(); const response = await fetch(url); let { results } = (await response.json()) as { results: { url: string; title: string; content: string }[]; }; const searchResults: [title: string, content: string, url: string][] = []; if (results) { if (limit && limit > 0) { results = results.slice(0, limit); } const uniqueUrls = new Set(); for (const result of results) { if (!result.content || uniqueUrls.has(result.url)) continue; const stripHtmlTags = (str: string) => str.replace(/<[^>]*>?/gm, ""); const content = stripHtmlTags(result.content).trim(); if (content === "") continue; const title = stripHtmlTags(result.title); const url = result.url; searchResults.push([title, content, url]); uniqueUrls.add(url); } } return searchResults; } catch (e) { console.error(e); return []; } } function getSearchTokenFilePath() { return path.resolve(temporaryDirectory, "minisearch-token"); } function regenerateSearchToken() { const newToken = Math.random().toString(36).substring(2); writeFileSync(getSearchTokenFilePath(), newToken); } function getSearchToken() { if (!existsSync(getSearchTokenFilePath())) regenerateSearchToken(); return readFileSync(getSearchTokenFilePath(), "utf8"); } let embeddingModelInstance: EmbeddingsModel | undefined; async function getSimilarityScores(query: string, documents: string[]) { if (!embeddingModelInstance) { embeddingModelInstance = await initModel(embeddingModel); } const [queryEmbedding] = await embeddingModelInstance.embed([query]); const documentsEmbeddings = await embeddingModelInstance.embed(documents); return documentsEmbeddings.map((documentEmbedding) => calculateSimilarity(queryEmbedding, documentEmbedding), ); } async function rankSearchResults( query: string, searchResults: [title: string, content: string, url: string][], ) { const scores = await getSimilarityScores( query.toLocaleLowerCase(), searchResults.map(([title, snippet, url]) => `${title}\n${url}\n${snippet}`.toLocaleLowerCase(), ), ); const searchResultToScoreMap: Map<(typeof searchResults)[0], number> = new Map(); scores.map((score, index) => searchResultToScoreMap.set(searchResults[index], score ?? 0), ); return searchResults .slice() .sort( (a, b) => (searchResultToScoreMap.get(b) ?? 0) - (searchResultToScoreMap.get(a) ?? 0), ); }