prithivMLmods commited on
Commit
a7634ef
·
verified ·
1 Parent(s): d04212d

Upload 39 files

Browse files
.dockerignore ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
25
+
26
+ # Folder for testing Transformer.js models locally
27
+ /public/models
.editorconfig ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ [*]
2
+ charset = utf-8
3
+ insert_final_newline = true
4
+ end_of_line = lf
5
+ indent_style = space
6
+ indent_size = 2
7
+ max_line_length = 80
.github/workflows/check-docker-container.yml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Check Docker Container
2
+ on:
3
+ push:
4
+ branches: ["main"]
5
+ pull_request:
6
+ branches: ["main"]
7
+ jobs:
8
+ docker:
9
+ name: Check Docker Container
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - run: docker-compose -f docker-compose.production.yml up -d
14
+ - name: Check if main page is available
15
+ run: until curl -s -o /dev/null -w "%{http_code}" localhost:7860 | grep 200; do sleep 1; done
16
+ - run: docker-compose -f docker-compose.production.yml down
.github/workflows/code-quality.yml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Code Quality
2
+ on:
3
+ push:
4
+ branches: ["main"]
5
+ pull_request:
6
+ branches: ["main"]
7
+ jobs:
8
+ lint:
9
+ name: Lint
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-node@v4
14
+ with:
15
+ node-version: 20
16
+ cache: "npm"
17
+ - run: npm ci
18
+ - run: npm run lint
.github/workflows/huggingface-sync.yml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face Spaces
2
+ on:
3
+ push:
4
+ branches:
5
+ - main
6
+ jobs:
7
+ sync:
8
+ name: Sync
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Checkout Repository
12
+ uses: actions/checkout@v4
13
+ with:
14
+ lfs: true
15
+ - name: Sync to Hugging Face Spaces
16
+ uses: JacobLinCool/huggingface-sync@v1
17
+ with:
18
+ github: ${{ secrets.GITHUB_TOKEN }}
19
+ user: ${{ vars.HF_SPACE_OWNER }}
20
+ space: ${{ vars.HF_SPACE_NAME }}
21
+ token: ${{ secrets.HF_TOKEN }}
22
+ configuration: "hf-space-config.yml"
.github/workflows/publish-docker-image.yml ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish Docker image to GitHub Packages
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+
7
+ env:
8
+ REGISTRY: ghcr.io
9
+ IMAGE_NAME: ${{ github.repository }}
10
+
11
+ jobs:
12
+ build-and-push-image:
13
+ runs-on: ubuntu-latest
14
+ permissions:
15
+ contents: read
16
+ packages: write
17
+ steps:
18
+ - name: Checkout repository
19
+ uses: actions/checkout@v4
20
+ - name: Log in to the Container registry
21
+ uses: docker/login-action@7840e6ddd4a9223910798f6a315544257fccd96e
22
+ with:
23
+ registry: ${{ env.REGISTRY }}
24
+ username: ${{ github.actor }}
25
+ password: ${{ secrets.GITHUB_TOKEN }}
26
+ - name: Extract metadata (tags, labels) for Docker
27
+ id: meta
28
+ uses: docker/metadata-action@2ee3d3070bb41b40bf7305d15233321e12c1dc5c
29
+ with:
30
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
31
+ - name: Build and push Docker image
32
+ uses: docker/build-push-action@729f7f49266dec5e72fe7339273d3f7e65abacd7
33
+ with:
34
+ context: .
35
+ push: true
36
+ tags: ${{ steps.meta.outputs.tags }}
37
+ labels: ${{ steps.meta.outputs.labels }}
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ node_modules
2
+ client/dist
3
+ .DS_Store
.npmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ legacy-peer-deps = true
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM searxng/searxng:2024.4.29-e45a7cc06
2
+ ENV PORT ${PORT:-7860}
3
+ EXPOSE ${PORT}
4
+ RUN apk add --update \
5
+ nodejs \
6
+ npm \
7
+ git
8
+ RUN sed -i 's/- html/- json/' /usr/local/searxng/searx/settings.yml \
9
+ && sed -i 's/su-exec searxng:searxng //' /usr/local/searxng/dockerfiles/docker-entrypoint.sh \
10
+ && mkdir -p /etc/searxng \
11
+ && chmod 777 /etc/searxng
12
+ ARG USERNAME=user
13
+ RUN adduser -D -u 1000 ${USERNAME} \
14
+ && mkdir -p /home/${USERNAME}/app \
15
+ && chown -R ${USERNAME}:${USERNAME} /home/${USERNAME}
16
+ USER user
17
+ WORKDIR /home/${USERNAME}/app
18
+ COPY --chown=${USERNAME}:${USERNAME} . .
19
+ RUN npm ci \
20
+ && npm run build
21
+ ENTRYPOINT [ "/bin/sh", "-c" ]
22
+ CMD [ "/usr/local/searxng/dockerfiles/docker-entrypoint.sh -f & npm start -- --host" ]
client/components/App.tsx ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { usePubSub } from "create-pubsub/react";
2
+ import {
3
+ promptPubSub,
4
+ responsePubSub,
5
+ searchResultsPubSub,
6
+ urlsDescriptionsPubSub,
7
+ } from "../modules/pubSub";
8
+ import { SearchForm } from "./SearchForm";
9
+ import { Toaster } from "react-hot-toast";
10
+ import Markdown from "markdown-to-jsx";
11
+ import { getDisableAiResponseSetting } from "../modules/pubSub";
12
+ import { SearchResultsList } from "./SearchResultsList";
13
+ import { useEffect } from "react";
14
+ import { prepareTextGeneration } from "../modules/textGeneration";
15
+
16
+ export function App() {
17
+ const [query, updateQuery] = usePubSub(promptPubSub);
18
+ const [response] = usePubSub(responsePubSub);
19
+ const [searchResults] = usePubSub(searchResultsPubSub);
20
+ const [urlsDescriptions] = usePubSub(urlsDescriptionsPubSub);
21
+
22
+ useEffect(() => {
23
+ prepareTextGeneration();
24
+ }, []);
25
+
26
+ return (
27
+ <>
28
+ <SearchForm query={query} updateQuery={updateQuery} />
29
+ {!getDisableAiResponseSetting() && response.length > 0 && (
30
+ <div
31
+ style={{
32
+ backgroundColor: "var(--background)",
33
+ borderRadius: "6px",
34
+ padding: "10px 25px",
35
+ }}
36
+ >
37
+ <Markdown>{response}</Markdown>
38
+ </div>
39
+ )}
40
+ {searchResults.length > 0 && (
41
+ <div>
42
+ <SearchResultsList
43
+ searchResults={searchResults}
44
+ urlsDescriptions={urlsDescriptions}
45
+ />
46
+ </div>
47
+ )}
48
+ <Toaster />
49
+ </>
50
+ );
51
+ }
client/components/SearchForm.tsx ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, FormEvent, useState, useCallback } from "react";
2
+ import TextareaAutosize from "react-textarea-autosize";
3
+ import { getRandomQuerySuggestion } from "../modules/querySuggestions";
4
+ import { SettingsButton } from "./SettingsButton";
5
+
6
+ export function SearchForm({
7
+ query,
8
+ updateQuery,
9
+ }: {
10
+ query: string;
11
+ updateQuery: (query: string) => void;
12
+ }) {
13
+ const textAreaRef = useRef<HTMLTextAreaElement>(null);
14
+ const windowInnerHeight = useWindowInnerHeight();
15
+ const [suggestedQuery, setSuggestedQuery] = useState("");
16
+
17
+ useEffect(() => {
18
+ getRandomQuerySuggestion().then((querySuggestion) => {
19
+ setSuggestedQuery(querySuggestion);
20
+ });
21
+ }, []);
22
+
23
+ const handleInputChange = async (
24
+ event: React.ChangeEvent<HTMLTextAreaElement>,
25
+ ) => {
26
+ const userQueryIsBlank = event.target.value.length === 0;
27
+ const suggestedQueryIsBlank = suggestedQuery.length === 0;
28
+
29
+ if (userQueryIsBlank && suggestedQueryIsBlank) {
30
+ setSuggestedQuery(await getRandomQuerySuggestion());
31
+ } else if (!userQueryIsBlank && !suggestedQueryIsBlank) {
32
+ setSuggestedQuery("");
33
+ }
34
+ };
35
+
36
+ const startSearching = useCallback(() => {
37
+ let queryToEncode = suggestedQuery;
38
+
39
+ if (textAreaRef.current && textAreaRef.current.value.trim().length > 0) {
40
+ queryToEncode = textAreaRef.current.value;
41
+ }
42
+
43
+ self.history.pushState(
44
+ null,
45
+ "",
46
+ `/?q=${encodeURIComponent(queryToEncode)}`,
47
+ );
48
+
49
+ updateQuery(queryToEncode);
50
+
51
+ location.reload();
52
+ }, [suggestedQuery, updateQuery]);
53
+
54
+ const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
55
+ event.preventDefault();
56
+ startSearching();
57
+ };
58
+
59
+ useEffect(() => {
60
+ const keyboardEventHandler = (event: KeyboardEvent) => {
61
+ if (event.code === "Enter" && !event.shiftKey) {
62
+ event.preventDefault();
63
+ startSearching();
64
+ }
65
+ };
66
+ const textArea = textAreaRef.current;
67
+ textArea?.addEventListener("keypress", keyboardEventHandler);
68
+ return () => {
69
+ textArea?.removeEventListener("keypress", keyboardEventHandler);
70
+ };
71
+ }, [startSearching]);
72
+
73
+ return (
74
+ <div
75
+ style={
76
+ query.length === 0
77
+ ? {
78
+ display: "flex",
79
+ justifyContent: "center",
80
+ alignItems: "center",
81
+ height: windowInnerHeight * 0.8,
82
+ }
83
+ : undefined
84
+ }
85
+ >
86
+ <form onSubmit={handleSubmit} style={{ width: "100%" }}>
87
+ <TextareaAutosize
88
+ defaultValue={query}
89
+ placeholder={suggestedQuery}
90
+ ref={textAreaRef}
91
+ onChange={handleInputChange}
92
+ autoFocus
93
+ minRows={1}
94
+ maxRows={6}
95
+ />
96
+ <div style={{ display: "flex", width: "100%" }}>
97
+ <button type="submit" style={{ width: "100%", fontSize: "small" }}>
98
+ Search
99
+ </button>
100
+ <SettingsButton />
101
+ </div>
102
+ </form>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ function useWindowInnerHeight() {
108
+ const [windowInnerHeight, setWindowInnerHeight] = useState(self.innerHeight);
109
+
110
+ useEffect(() => {
111
+ const handleResize = () => setWindowInnerHeight(self.innerHeight);
112
+
113
+ self.addEventListener("resize", handleResize);
114
+
115
+ return () => self.removeEventListener("resize", handleResize);
116
+ }, []);
117
+
118
+ return windowInnerHeight;
119
+ }
client/components/SearchResultsList.tsx ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { SearchResults } from "../modules/search";
3
+ import { Tooltip } from "react-tooltip";
4
+ import Markdown from "markdown-to-jsx";
5
+
6
+ export function SearchResultsList({
7
+ searchResults,
8
+ urlsDescriptions,
9
+ }: {
10
+ searchResults: SearchResults;
11
+ urlsDescriptions: Record<string, string>;
12
+ }) {
13
+ const [windowWidth, setWindowWidth] = useState(self.innerWidth);
14
+
15
+ useEffect(() => {
16
+ const handleResize = () => {
17
+ setWindowWidth(self.innerWidth);
18
+ };
19
+
20
+ self.addEventListener("resize", handleResize);
21
+
22
+ return () => {
23
+ self.removeEventListener("resize", handleResize);
24
+ };
25
+ }, []);
26
+
27
+ const shouldDisplayDomainBelowTitle = windowWidth < 720;
28
+
29
+ return (
30
+ <ul>
31
+ {searchResults.map(([title, snippet, url], index) => (
32
+ <li key={url}>
33
+ <Tooltip
34
+ id={`search-result-${index}`}
35
+ place="top-start"
36
+ variant="info"
37
+ opacity="1"
38
+ style={{ width: "75vw", maxWidth: "600px" }}
39
+ >
40
+ {snippet}
41
+ <br />
42
+ <br />
43
+ {url}
44
+ </Tooltip>
45
+ <div
46
+ style={{
47
+ display: "flex",
48
+ justifyContent: "space-between",
49
+ gap: shouldDisplayDomainBelowTitle ? 0 : "1rem",
50
+ flexDirection: shouldDisplayDomainBelowTitle ? "column" : "row",
51
+ }}
52
+ >
53
+ <a
54
+ href={url}
55
+ data-tooltip-id={`search-result-${index}`}
56
+ target="_blank"
57
+ >
58
+ {title}
59
+ </a>
60
+ <a href={url} target="_blank">
61
+ <cite
62
+ style={{
63
+ fontSize: "small",
64
+ color: "gray",
65
+ whiteSpace: "nowrap",
66
+ }}
67
+ >
68
+ {new URL(url).hostname.replace("www.", "")}
69
+ </cite>
70
+ </a>
71
+ </div>
72
+ {urlsDescriptions[url] && (
73
+ <blockquote>
74
+ <Markdown>{urlsDescriptions[url]}</Markdown>
75
+ </blockquote>
76
+ )}
77
+ </li>
78
+ ))}
79
+ </ul>
80
+ );
81
+ }
client/components/SettingsButton.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { SettingsForm } from "./SettingsForm";
3
+ import { toast } from "react-hot-toast";
4
+
5
+ export function SettingsButton() {
6
+ const [isToastOpen, setToastOpen] = useState(false);
7
+
8
+ const toastId = "settings-toast";
9
+
10
+ const openToast = () => {
11
+ setToastOpen(true);
12
+
13
+ toast(
14
+ <div>
15
+ <SettingsForm />
16
+ <div
17
+ style={{
18
+ display: "flex",
19
+ justifyContent: "center",
20
+ marginTop: "8px",
21
+ }}
22
+ >
23
+ <button style={{ fontSize: "small" }} onClick={closeToast}>
24
+ Done
25
+ </button>
26
+ </div>
27
+ </div>,
28
+ {
29
+ id: toastId,
30
+ duration: Infinity,
31
+ position: "bottom-center",
32
+ style: {
33
+ borderRadius: "10px",
34
+ background: "var(--background)",
35
+ color: "var(--text-main)",
36
+ },
37
+ },
38
+ );
39
+ };
40
+
41
+ const closeToast = () => {
42
+ setToastOpen(false);
43
+ toast.dismiss(toastId);
44
+ };
45
+
46
+ return (
47
+ <button
48
+ style={{ fontSize: "small", marginRight: 0 }}
49
+ onClick={(event) => {
50
+ event.preventDefault();
51
+ isToastOpen ? closeToast() : openToast();
52
+ }}
53
+ >
54
+ Settings
55
+ </button>
56
+ );
57
+ }
client/components/SettingsForm.tsx ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { usePubSub } from "create-pubsub/react";
2
+ import {
3
+ disableAiResponseSettingPubSub,
4
+ summarizeLinksSettingPubSub,
5
+ useLargerModelSettingPubSub,
6
+ disableWebGpuUsageSettingPubSub,
7
+ } from "../modules/pubSub";
8
+ import { Tooltip } from "react-tooltip";
9
+ import { isWebGPUAvailable } from "../modules/webGpu";
10
+ import type { ChangeEventHandler } from "react";
11
+
12
+ export function SettingsForm() {
13
+ const [disableAiResponse, setDisableAiResponse] = usePubSub(
14
+ disableAiResponseSettingPubSub,
15
+ );
16
+ const [summarizeLinks, setSummarizeLinks] = usePubSub(
17
+ summarizeLinksSettingPubSub,
18
+ );
19
+ const [useLargerModel, setUseLargerModel] = usePubSub(
20
+ useLargerModelSettingPubSub,
21
+ );
22
+ const [disableWebGpuUsage, setDisableWebGpuUsage] = usePubSub(
23
+ disableWebGpuUsageSettingPubSub,
24
+ );
25
+
26
+ return (
27
+ <div>
28
+ <div
29
+ style={{
30
+ textAlign: "center",
31
+ fontSize: "16px",
32
+ fontWeight: "bolder",
33
+ margin: "10px",
34
+ }}
35
+ >
36
+ Settings
37
+ </div>
38
+ <div>
39
+ <SettingCheckbox
40
+ label="Use a larger AI model"
41
+ checked={useLargerModel}
42
+ onChange={(event) => setUseLargerModel(event.target.checked)}
43
+ tooltipId="use-large-model-setting-tooltip"
44
+ tooltipContent="Generates better responses, but takes longer to load"
45
+ />
46
+ </div>
47
+ <div>
48
+ <SettingCheckbox
49
+ label="Summarize links"
50
+ checked={summarizeLinks}
51
+ onChange={(event) => setSummarizeLinks(event.target.checked)}
52
+ tooltipId="summarize-links-setting-tooltip"
53
+ tooltipContent="Provides a short overview for each of the links from the web search results"
54
+ />
55
+ </div>
56
+ {isWebGPUAvailable && (
57
+ <div>
58
+ <SettingCheckbox
59
+ label="Disable WebGPU usage"
60
+ checked={disableWebGpuUsage}
61
+ onChange={(event) => setDisableWebGpuUsage(event.target.checked)}
62
+ tooltipId="use-large-model-setting-tooltip"
63
+ tooltipContent="Disables the WebGPU and run smaller AI models only using the CPU"
64
+ />
65
+ </div>
66
+ )}
67
+ <div>
68
+ <SettingCheckbox
69
+ label="Disable AI response"
70
+ checked={disableAiResponse}
71
+ onChange={(event) => setDisableAiResponse(event.target.checked)}
72
+ tooltipId="disable-ai-setting-tooltip"
73
+ tooltipContent="Disables the AI response, in case you only want to see the links from the web search results"
74
+ />
75
+ </div>
76
+ </div>
77
+ );
78
+ }
79
+
80
+ function SettingCheckbox(props: {
81
+ label: string;
82
+ checked?: boolean;
83
+ onChange?: ChangeEventHandler<HTMLInputElement>;
84
+ tooltipId?: string;
85
+ tooltipContent?: string;
86
+ }) {
87
+ return (
88
+ <>
89
+ <Tooltip
90
+ id={props.tooltipId}
91
+ place="top-start"
92
+ variant="info"
93
+ opacity="1"
94
+ style={{ maxWidth: "90vw" }}
95
+ />
96
+ <label
97
+ data-tooltip-id={props.tooltipId}
98
+ data-tooltip-content={props.tooltipContent}
99
+ >
100
+ <input
101
+ type="checkbox"
102
+ checked={props.checked}
103
+ onChange={props.onChange}
104
+ />
105
+ {props.label}
106
+ </label>
107
+ </>
108
+ );
109
+ }
client/index.css ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url("../node_modules/water.css/out/water.css");
2
+ @import url("../node_modules/inter-ui/inter.css");
3
+ @import url("../node_modules/inter-ui/inter-variable.css");
4
+ @import url("../node_modules/react-tooltip/dist/react-tooltip.css");
5
+
6
+ :root,
7
+ html,
8
+ body {
9
+ font-family: Inter, sans-serif;
10
+ font-feature-settings:
11
+ "liga" 1,
12
+ "calt" 1;
13
+ }
14
+
15
+ @supports (font-variation-settings: normal) {
16
+ :root,
17
+ html,
18
+ body {
19
+ font-family: InterVariable, sans-serif;
20
+ }
21
+ }
client/index.html ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta
6
+ name="viewport"
7
+ content="width=device-width, initial-scale=1.0, user-scalable=no"
8
+ />
9
+ <meta
10
+ name="description"
11
+ content="Minimalist web-searching app with an AI assistant that is always available and runs directly from your browser."
12
+ />
13
+ <meta itemprop="name" content="MiniSearch" />
14
+ <meta
15
+ itemprop="description"
16
+ content="Minimalist web-searching app with an AI assistant that is always available and runs directly from your browser."
17
+ />
18
+ <meta property="og:type" content="website" />
19
+ <meta property="og:title" content="MiniSearch" />
20
+ <meta
21
+ property="og:description"
22
+ content="Minimalist web-searching app with an AI assistant that is always available and runs directly from your browser."
23
+ />
24
+ <meta name="twitter:card" content="summary" />
25
+ <meta name="twitter:title" content="MiniSearch" />
26
+ <meta
27
+ name="twitter:description"
28
+ content="Minimalist web-searching app with an AI assistant that is always available and runs directly from your browser."
29
+ />
30
+ <title>MiniSearch</title>
31
+ <link
32
+ rel="icon"
33
+ href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🔍</text></svg>"
34
+ />
35
+ <link rel="stylesheet" href="./index.css" />
36
+ </head>
37
+ <body>
38
+ <script type="module" src="./index.tsx"></script>
39
+ </body>
40
+ </html>
client/index.tsx ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { createRoot } from "react-dom/client";
2
+ import { App } from "./components/App";
3
+
4
+ createRoot(document.body.appendChild(document.createElement("div"))).render(
5
+ <App />,
6
+ );
client/modules/mobileDetection.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import MobileDetect from "mobile-detect";
2
+
3
+ export const isRunningOnMobile =
4
+ new MobileDetect(self.navigator.userAgent).mobile() !== null;
client/modules/pubSub.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createPubSub } from "create-pubsub";
2
+ import { SearchResults } from "./search";
3
+
4
+ function createLocalStoragePubSub<T>(localStorageKey: string, defaultValue: T) {
5
+ const localStorageValue = localStorage.getItem(localStorageKey);
6
+ const localStoragePubSub = createPubSub(
7
+ localStorageValue ? (JSON.parse(localStorageValue) as T) : defaultValue,
8
+ );
9
+
10
+ const [, onValueChange] = localStoragePubSub;
11
+
12
+ onValueChange((value) =>
13
+ localStorage.setItem(localStorageKey, JSON.stringify(value)),
14
+ );
15
+
16
+ return localStoragePubSub;
17
+ }
18
+
19
+ export const disableAiResponseSettingPubSub = createLocalStoragePubSub(
20
+ "disableAiResponse",
21
+ false,
22
+ );
23
+
24
+ export const [, , getDisableAiResponseSetting] = disableAiResponseSettingPubSub;
25
+
26
+ export const summarizeLinksSettingPubSub = createLocalStoragePubSub(
27
+ "summarizeLinks",
28
+ false,
29
+ );
30
+
31
+ export const [, , getSummarizeLinksSetting] = summarizeLinksSettingPubSub;
32
+
33
+ export const useLargerModelSettingPubSub = createLocalStoragePubSub(
34
+ "useLargerModel",
35
+ false,
36
+ );
37
+
38
+ export const [, , getUseLargerModelSetting] = useLargerModelSettingPubSub;
39
+
40
+ export const disableWebGpuUsageSettingPubSub = createLocalStoragePubSub(
41
+ "disableWebGpuUsage",
42
+ false,
43
+ );
44
+
45
+ export const [, , getDisableWebGpuUsageSetting] =
46
+ disableWebGpuUsageSettingPubSub;
47
+
48
+ export const querySuggestionsPubSub = createLocalStoragePubSub<string[]>(
49
+ "querySuggestions",
50
+ [],
51
+ );
52
+
53
+ export const [updateQuerySuggestions, , getQuerySuggestions] =
54
+ querySuggestionsPubSub;
55
+
56
+ export const promptPubSub = createPubSub("");
57
+
58
+ export const [updatePrompt] = promptPubSub;
59
+
60
+ export const responsePubSub = createPubSub("");
61
+
62
+ export const [updateResponse] = responsePubSub;
63
+
64
+ export const searchResultsPubSub = createPubSub<SearchResults>([]);
65
+
66
+ export const [updateSearchResults, , getSearchResults] = searchResultsPubSub;
67
+
68
+ export const urlsDescriptionsPubSub = createPubSub<Record<string, string>>({});
69
+
70
+ export const [updateUrlsDescriptions, , getUrlsDescriptions] =
71
+ urlsDescriptionsPubSub;
client/modules/querySuggestions.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getQuerySuggestions, updateQuerySuggestions } from "./pubSub";
2
+
3
+ export async function getRandomQuerySuggestion() {
4
+ if (getQuerySuggestions().length === 0) await refillQuerySuggestions(25);
5
+
6
+ const querySuggestions = getQuerySuggestions();
7
+
8
+ const randomQuerySuggestion = querySuggestions.pop() as string;
9
+
10
+ updateQuerySuggestions(querySuggestions);
11
+
12
+ return randomQuerySuggestion;
13
+ }
14
+
15
+ async function refillQuerySuggestions(limit?: number) {
16
+ const querySuggestionsFileUrl = new URL(
17
+ "/query-suggestions.json",
18
+ self.location.origin,
19
+ );
20
+
21
+ const fetchResponse = await fetch(querySuggestionsFileUrl.toString());
22
+
23
+ const querySuggestionsList: string[] = await fetchResponse.json();
24
+
25
+ updateQuerySuggestions(
26
+ querySuggestionsList.sort(() => Math.random() - 0.5).slice(0, limit),
27
+ );
28
+ }
client/modules/ratchet.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Model,
3
+ Quantization,
4
+ default as initialize,
5
+ } from "@ratchet-ml/ratchet-web";
6
+ import ratchetWasmUrl from "@ratchet-ml/ratchet-web/ratchet-web_bg.wasm?url";
7
+
8
+ let model: Model | undefined;
9
+
10
+ export async function initializeRatchet(
11
+ handleLoadingProgress: (loadingProgressPercentage: number) => void,
12
+ ) {
13
+ await initialize(ratchetWasmUrl);
14
+
15
+ model = await Model.load(
16
+ { Phi: "phi3" },
17
+ Quantization.Q8_0,
18
+ handleLoadingProgress,
19
+ );
20
+ }
21
+
22
+ export async function runCompletion(
23
+ prompt: string,
24
+ callback: (completionChunk: string) => void,
25
+ ) {
26
+ if (!model) throw new Error("Ratchet is not initialized.");
27
+
28
+ await model.run({ prompt, callback });
29
+ }
30
+
31
+ export async function exitRatchet() {
32
+ if (!model) throw new Error("Ratchet is not initialized.");
33
+
34
+ model.free();
35
+
36
+ model = undefined;
37
+ }
client/modules/search.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { convert as convertHtmlToPlainText } from "html-to-text";
2
+
3
+ export type SearchResults = [title: string, snippet: string, url: string][];
4
+
5
+ export async function search(query: string, limit?: number) {
6
+ const searchUrl = new URL("/search", self.location.origin);
7
+
8
+ searchUrl.searchParams.set("q", query);
9
+ searchUrl.searchParams.set("token", VITE_SEARCH_TOKEN);
10
+
11
+ if (limit && limit > 0) {
12
+ searchUrl.searchParams.set("limit", limit.toString());
13
+ }
14
+
15
+ const response = await fetch(searchUrl.toString());
16
+
17
+ if (!response.ok) return [];
18
+
19
+ const searchResults = (await response.json()) as SearchResults;
20
+
21
+ const cleanedSearchResults = searchResults.map(([title, snippet, url]) => [
22
+ convertHtmlToPlainText(title, { wordwrap: false }),
23
+ convertHtmlToPlainText(snippet, { wordwrap: false }),
24
+ url,
25
+ ]) as SearchResults;
26
+
27
+ return cleanedSearchResults;
28
+ }
client/modules/textGeneration.ts ADDED
@@ -0,0 +1,526 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { isWebGPUAvailable } from "./webGpu";
2
+ import {
3
+ updatePrompt,
4
+ updateSearchResults,
5
+ getDisableAiResponseSetting,
6
+ getSummarizeLinksSetting,
7
+ getUseLargerModelSetting,
8
+ updateResponse,
9
+ getSearchResults,
10
+ updateUrlsDescriptions,
11
+ getUrlsDescriptions,
12
+ getDisableWebGpuUsageSetting,
13
+ } from "./pubSub";
14
+ import { SearchResults, search } from "./search";
15
+ import { query, debug } from "./urlParams";
16
+ import toast from "react-hot-toast";
17
+ import { isRunningOnMobile } from "./mobileDetection";
18
+
19
+ export async function prepareTextGeneration() {
20
+ if (query === null) return;
21
+
22
+ document.title = query;
23
+
24
+ updatePrompt(query);
25
+
26
+ updateLoadingToast("Searching the web...");
27
+
28
+ let searchResults = await search(
29
+ query.length > 2000 ? (await getKeywords(query, 20)).join(" ") : query,
30
+ 30,
31
+ );
32
+
33
+ if (searchResults.length === 0) {
34
+ const queryKeywords = await getKeywords(query, 10);
35
+
36
+ searchResults = await search(queryKeywords.join(" "), 30);
37
+ }
38
+
39
+ if (searchResults.length === 0) {
40
+ toast(
41
+ "It looks like your current search did not return any results. Try refining your search by adding more keywords or rephrasing your query.",
42
+ {
43
+ position: "bottom-center",
44
+ duration: 10000,
45
+ icon: "💡",
46
+ },
47
+ );
48
+ }
49
+
50
+ updateSearchResults(searchResults);
51
+
52
+ updateUrlsDescriptions(
53
+ searchResults.reduce(
54
+ (acc, [, snippet, url]) => ({ ...acc, [url]: snippet }),
55
+ {},
56
+ ),
57
+ );
58
+
59
+ dismissLoadingToast();
60
+
61
+ if (getDisableAiResponseSetting() && !getSummarizeLinksSetting()) return;
62
+
63
+ if (debug) console.time("Response Generation Time");
64
+
65
+ updateLoadingToast("Loading AI model...");
66
+
67
+ try {
68
+ try {
69
+ if (!isWebGPUAvailable) throw Error("WebGPU is not available.");
70
+
71
+ if (getDisableWebGpuUsageSetting()) throw Error("WebGPU is disabled.");
72
+
73
+ if (getUseLargerModelSetting()) {
74
+ try {
75
+ await generateTextWithWebLlm();
76
+ } catch (error) {
77
+ await generateTextWithRatchet();
78
+ }
79
+ } else {
80
+ try {
81
+ await generateTextWithRatchet();
82
+ } catch (error) {
83
+ await generateTextWithWebLlm();
84
+ }
85
+ }
86
+ } catch (error) {
87
+ await generateTextWithWllama();
88
+ }
89
+ } catch (error) {
90
+ console.error("Error while generating response with wllama:", error);
91
+
92
+ toast.error(
93
+ "Could not generate response. The browser may be out of memory. Please close this tab and run this search again in a new one.",
94
+ { duration: 10000 },
95
+ );
96
+ } finally {
97
+ dismissLoadingToast();
98
+ }
99
+
100
+ if (debug) {
101
+ console.timeEnd("Response Generation Time");
102
+ }
103
+ }
104
+
105
+ function updateLoadingToast(text: string) {
106
+ toast.loading(text, {
107
+ id: "text-generation-loading-toast",
108
+ position: "bottom-center",
109
+ });
110
+ }
111
+
112
+ function dismissLoadingToast() {
113
+ toast.dismiss("text-generation-loading-toast");
114
+ }
115
+
116
+ async function generateTextWithWebLlm() {
117
+ const { CreateWebWorkerEngine, CreateEngine, hasModelInCache } = await import(
118
+ "@mlc-ai/web-llm"
119
+ );
120
+
121
+ const availableModels = {
122
+ Llama: "Llama-3-8B-Instruct-q4f16_1",
123
+ Mistral: "Mistral-7B-Instruct-v0.2-q4f16_1",
124
+ Gemma: "gemma-2b-it-q4f16_1",
125
+ Phi: "Phi2-q4f16_1",
126
+ TinyLlama: "TinyLlama-1.1B-Chat-v0.4-q0f16",
127
+ };
128
+
129
+ const selectedModel = getUseLargerModelSetting()
130
+ ? availableModels.Llama
131
+ : availableModels.Gemma;
132
+
133
+ const isModelCached = await hasModelInCache(selectedModel);
134
+
135
+ let initProgressCallback:
136
+ | import("@mlc-ai/web-llm").InitProgressCallback
137
+ | undefined;
138
+
139
+ if (isModelCached) {
140
+ updateLoadingToast("Generating response...");
141
+ } else {
142
+ initProgressCallback = (report) => {
143
+ updateLoadingToast(
144
+ `Loading: ${report.text.replaceAll("[", "(").replaceAll("]", ")")}`,
145
+ );
146
+ };
147
+ }
148
+
149
+ const engine = Worker
150
+ ? await CreateWebWorkerEngine(
151
+ new Worker(new URL("./webLlmWorker.ts", import.meta.url), {
152
+ type: "module",
153
+ }),
154
+ selectedModel,
155
+ { initProgressCallback },
156
+ )
157
+ : await CreateEngine(selectedModel, { initProgressCallback });
158
+
159
+ if (!getDisableAiResponseSetting()) {
160
+ updateLoadingToast("Generating response...");
161
+
162
+ const completion = await engine.chat.completions.create({
163
+ stream: true,
164
+ messages: [{ role: "user", content: getMainPrompt() }],
165
+ max_gen_len: 768,
166
+ });
167
+
168
+ let streamedMessage = "";
169
+
170
+ for await (const chunk of completion) {
171
+ const deltaContent = chunk.choices[0].delta.content;
172
+
173
+ if (deltaContent) streamedMessage += deltaContent;
174
+
175
+ updateResponse(streamedMessage);
176
+ }
177
+ }
178
+
179
+ await engine.resetChat();
180
+
181
+ if (getSummarizeLinksSetting()) {
182
+ updateLoadingToast("Summarizing links...");
183
+
184
+ for (const [title, snippet, url] of getSearchResults()) {
185
+ const completion = await engine.chat.completions.create({
186
+ stream: true,
187
+ messages: [
188
+ {
189
+ role: "user",
190
+ content: await getLinkSummarizationPrompt([title, snippet, url]),
191
+ },
192
+ ],
193
+ max_gen_len: 768,
194
+ });
195
+
196
+ let streamedMessage = "";
197
+
198
+ for await (const chunk of completion) {
199
+ const deltaContent = chunk.choices[0].delta.content;
200
+
201
+ if (deltaContent) streamedMessage += deltaContent;
202
+
203
+ updateUrlsDescriptions({
204
+ ...getUrlsDescriptions(),
205
+ [url]: streamedMessage,
206
+ });
207
+ }
208
+
209
+ await engine.resetChat();
210
+ }
211
+ }
212
+
213
+ if (debug) {
214
+ console.info(await engine.runtimeStatsText());
215
+ }
216
+
217
+ engine.unload();
218
+ }
219
+
220
+ async function generateTextWithWllama() {
221
+ const { initializeWllama, runCompletion, exitWllama } = await import(
222
+ "./wllama"
223
+ );
224
+
225
+ const commonSamplingConfig: import("@wllama/wllama").SamplingConfig = {
226
+ temp: 0.35,
227
+ dynatemp_range: 0.25,
228
+ top_k: 0,
229
+ top_p: 1,
230
+ min_p: 0.05,
231
+ tfs_z: 0.95,
232
+ typical_p: 0.85,
233
+ penalty_freq: 0.5,
234
+ penalty_repeat: 1.176,
235
+ penalty_last_n: -1,
236
+ mirostat: 2,
237
+ mirostat_tau: 3.5,
238
+ };
239
+
240
+ const availableModels: {
241
+ [key in
242
+ | "mobileDefault"
243
+ | "mobileLarger"
244
+ | "desktopDefault"
245
+ | "desktopLarger"]: {
246
+ url: string;
247
+ userPrefix: string;
248
+ assistantPrefix: string;
249
+ messageSuffix: string;
250
+ sampling: import("@wllama/wllama").SamplingConfig;
251
+ };
252
+ } = {
253
+ mobileDefault: {
254
+ url: "https://huggingface.co/Felladrin/gguf-vicuna-160m/resolve/main/vicuna-160m.Q8_0.gguf",
255
+ userPrefix: "USER:\n",
256
+ assistantPrefix: "ASSISTANT:\n",
257
+ messageSuffix: "</s>\n",
258
+ sampling: commonSamplingConfig,
259
+ },
260
+ mobileLarger: {
261
+ url: "https://huggingface.co/Felladrin/gguf-zephyr-220m-dpo-full/resolve/main/zephyr-220m-dpo-full.Q8_0.gguf",
262
+ userPrefix: "<|user|>\n",
263
+ assistantPrefix: "<|assistant|>\n",
264
+ messageSuffix: "</s>\n",
265
+ sampling: commonSamplingConfig,
266
+ },
267
+ desktopDefault: {
268
+ url: "https://huggingface.co/Felladrin/gguf-TinyLlama-1.1B-1T-OpenOrca/resolve/main/tinyllama-1.1b-1t-openorca.Q8_0.gguf",
269
+ userPrefix: "<|im_start|>user\n",
270
+ assistantPrefix: "<|im_start|>assistant\n",
271
+ messageSuffix: "<|im_end|>\n",
272
+ sampling: commonSamplingConfig,
273
+ },
274
+ desktopLarger: {
275
+ url: "https://huggingface.co/Felladrin/gguf-stablelm-2-1_6b-chat/resolve/main/stablelm-2-1_6b-chat.Q8_0.gguf",
276
+ userPrefix: "<|im_start|>user\n",
277
+ assistantPrefix: "<|im_start|>assistant\n",
278
+ messageSuffix: "<|im_end|>\n",
279
+ sampling: commonSamplingConfig,
280
+ },
281
+ };
282
+
283
+ const defaultModel = isRunningOnMobile
284
+ ? availableModels.mobileDefault
285
+ : availableModels.desktopDefault;
286
+
287
+ const largerModel = isRunningOnMobile
288
+ ? availableModels.mobileLarger
289
+ : availableModels.desktopLarger;
290
+
291
+ const selectedModel = getUseLargerModelSetting() ? largerModel : defaultModel;
292
+
293
+ await initializeWllama({
294
+ modelUrl: selectedModel.url,
295
+ modelConfig: {
296
+ n_ctx: 2048,
297
+ },
298
+ });
299
+
300
+ if (!getDisableAiResponseSetting()) {
301
+ const prompt = [
302
+ selectedModel.userPrefix,
303
+ "Hello!",
304
+ selectedModel.messageSuffix,
305
+ selectedModel.assistantPrefix,
306
+ "Hi! How can I help you?",
307
+ selectedModel.messageSuffix,
308
+ selectedModel.userPrefix,
309
+ ["Take a look at this info:", getFormattedSearchResults(5)].join("\n\n"),
310
+ selectedModel.messageSuffix,
311
+ selectedModel.assistantPrefix,
312
+ "Alright!",
313
+ selectedModel.messageSuffix,
314
+ selectedModel.userPrefix,
315
+ "Now I'm going to write my question, and if this info is useful you can use them in your answer. Ready?",
316
+ selectedModel.messageSuffix,
317
+ selectedModel.assistantPrefix,
318
+ "I'm ready to answer!",
319
+ selectedModel.messageSuffix,
320
+ selectedModel.userPrefix,
321
+ query,
322
+ selectedModel.messageSuffix,
323
+ selectedModel.assistantPrefix,
324
+ ].join("");
325
+
326
+ if (!query) throw Error("Query is empty.");
327
+
328
+ updateLoadingToast("Generating response...");
329
+
330
+ const completion = await runCompletion({
331
+ prompt,
332
+ nPredict: 768,
333
+ sampling: selectedModel.sampling,
334
+ onNewToken: (_token, _piece, currentText) => {
335
+ updateResponse(currentText);
336
+ },
337
+ });
338
+
339
+ updateResponse(completion);
340
+ }
341
+
342
+ if (getSummarizeLinksSetting()) {
343
+ updateLoadingToast("Summarizing links...");
344
+
345
+ for (const [title, snippet, url] of getSearchResults()) {
346
+ const prompt = [
347
+ selectedModel.userPrefix,
348
+ "Hello!",
349
+ selectedModel.messageSuffix,
350
+ selectedModel.assistantPrefix,
351
+ "Hi! How can I help you?",
352
+ selectedModel.messageSuffix,
353
+ selectedModel.userPrefix,
354
+ ["Context:", `${title}: ${snippet}`].join("\n"),
355
+ "\n",
356
+ ["Question:", "What is this text about?"].join("\n"),
357
+ selectedModel.messageSuffix,
358
+ selectedModel.assistantPrefix,
359
+ ["Answer:", "This text is about"].join("\n"),
360
+ ].join("");
361
+
362
+ const completion = await runCompletion({
363
+ prompt,
364
+ nPredict: 128,
365
+ sampling: selectedModel.sampling,
366
+ onNewToken: (_token, _piece, currentText) => {
367
+ updateUrlsDescriptions({
368
+ ...getUrlsDescriptions(),
369
+ [url]: `This link is about ${currentText}`,
370
+ });
371
+ },
372
+ });
373
+
374
+ updateUrlsDescriptions({
375
+ ...getUrlsDescriptions(),
376
+ [url]: `This link is about ${completion}`,
377
+ });
378
+ }
379
+ }
380
+
381
+ await exitWllama();
382
+ }
383
+
384
+ async function generateTextWithRatchet() {
385
+ const { initializeRatchet, runCompletion, exitRatchet } = await import(
386
+ "./ratchet"
387
+ );
388
+
389
+ await initializeRatchet((loadingProgressPercentage) =>
390
+ updateLoadingToast(`Loading: ${Math.floor(loadingProgressPercentage)}%`),
391
+ );
392
+
393
+ if (!getDisableAiResponseSetting()) {
394
+ if (!query) throw Error("Query is empty.");
395
+
396
+ updateLoadingToast("Generating response...");
397
+
398
+ let response = "";
399
+
400
+ await runCompletion(getMainPrompt(), (completionChunk) => {
401
+ response += completionChunk;
402
+ updateResponse(response);
403
+ });
404
+
405
+ if (!endsWithASign(response)) {
406
+ response += ".";
407
+ updateResponse(response);
408
+ }
409
+ }
410
+
411
+ if (getSummarizeLinksSetting()) {
412
+ updateLoadingToast("Summarizing links...");
413
+
414
+ for (const [title, snippet, url] of getSearchResults()) {
415
+ let response = "";
416
+
417
+ await runCompletion(
418
+ await getLinkSummarizationPrompt([title, snippet, url]),
419
+ (completionChunk) => {
420
+ response += completionChunk;
421
+ updateUrlsDescriptions({
422
+ ...getUrlsDescriptions(),
423
+ [url]: response,
424
+ });
425
+ },
426
+ );
427
+
428
+ if (!endsWithASign(response)) {
429
+ response += ".";
430
+ updateUrlsDescriptions({
431
+ ...getUrlsDescriptions(),
432
+ [url]: response,
433
+ });
434
+ }
435
+ }
436
+ }
437
+
438
+ await exitRatchet();
439
+ }
440
+
441
+ async function fetchPageContent(
442
+ url: string,
443
+ options?: {
444
+ maxLength?: number;
445
+ },
446
+ ) {
447
+ const response = await fetch(`https://r.jina.ai/${url}`);
448
+
449
+ if (!response) {
450
+ throw new Error("No response from server");
451
+ } else if (!response.ok) {
452
+ throw new Error(`HTTP error! status: ${response.status}`);
453
+ }
454
+
455
+ const text = await response.text();
456
+
457
+ return text.trim().substring(0, options?.maxLength);
458
+ }
459
+
460
+ function endsWithASign(text: string) {
461
+ return text.endsWith(".") || text.endsWith("!") || text.endsWith("?");
462
+ }
463
+
464
+ function getMainPrompt() {
465
+ return [
466
+ "Provide a concise response to the request below.",
467
+ "If the information from the Web Search Results below is useful, you can use it to complement your response. Otherwise, ignore it.",
468
+ "",
469
+ "Web Search Results:",
470
+ "",
471
+ getFormattedSearchResults(5),
472
+ "",
473
+ "Request:",
474
+ "",
475
+ query,
476
+ ].join("\n");
477
+ }
478
+
479
+ async function getLinkSummarizationPrompt([
480
+ title,
481
+ snippet,
482
+ url,
483
+ ]: SearchResults[0]) {
484
+ let prompt = "";
485
+
486
+ try {
487
+ const pageContent = await fetchPageContent(url, { maxLength: 2500 });
488
+
489
+ prompt = [
490
+ `The context below is related to a link found when searching for "${query}":`,
491
+ "",
492
+ "[BEGIN OF CONTEXT]",
493
+ `Snippet: ${snippet}`,
494
+ "",
495
+ pageContent,
496
+ "[END OF CONTEXT]",
497
+ "",
498
+ "Now, tell me: What is this link about and how is it related to the search?",
499
+ "",
500
+ "Note: Don't cite the link in your response. Just write a few sentences to indicate if it's worth visiting.",
501
+ ].join("\n");
502
+ } catch (error) {
503
+ prompt = [
504
+ `When searching for "${query}", this link was found: [${title}](${url} "${snippet}")`,
505
+ "",
506
+ "Now, tell me: What is this link about and how is it related to the search?",
507
+ "",
508
+ "Note: Don't cite the link in your response. Just write a few sentences to indicate if it's worth visiting.",
509
+ ].join("\n");
510
+ }
511
+
512
+ return prompt;
513
+ }
514
+
515
+ function getFormattedSearchResults(limit?: number) {
516
+ return getSearchResults()
517
+ .slice(0, limit)
518
+ .map(([title, snippet, url]) => `${title}\n${url}\n${snippet}`)
519
+ .join("\n\n");
520
+ }
521
+
522
+ async function getKeywords(text: string, limit?: number) {
523
+ return (await import("keyword-extractor")).default
524
+ .extract(text, { language: "english" })
525
+ .slice(0, limit);
526
+ }
client/modules/urlParams.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ const urlParams = new URLSearchParams(self.location.search);
2
+ export const debug = urlParams.has("debug");
3
+ export const beta = urlParams.has("beta");
4
+ export const query = urlParams.get("q");
client/modules/webGpu.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export let isWebGPUAvailable = "gpu" in navigator;
2
+
3
+ if (isWebGPUAvailable) {
4
+ try {
5
+ const adapter = await (
6
+ navigator as unknown as {
7
+ gpu: { requestAdapter: () => Promise<never> };
8
+ }
9
+ ).gpu.requestAdapter();
10
+ if (!adapter) {
11
+ throw Error("Couldn't request WebGPU adapter.");
12
+ }
13
+ isWebGPUAvailable = true;
14
+ } catch (error) {
15
+ isWebGPUAvailable = false;
16
+ }
17
+ }
client/modules/webLlmWorker.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { EngineWorkerHandler, Engine } from "@mlc-ai/web-llm";
2
+
3
+ const engine = new Engine();
4
+ const handler = new EngineWorkerHandler(engine);
5
+ self.onmessage = (msg: MessageEvent) => {
6
+ handler.onmessage(msg);
7
+ };
client/modules/wllama.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ LoadModelConfig,
3
+ SamplingConfig,
4
+ Wllama,
5
+ AssetsPathConfig,
6
+ } from "@wllama/wllama";
7
+ import singleThreadWllamaJsUrl from "@wllama/wllama/esm/single-thread/wllama.js?url";
8
+ import singleThreadWllamaWasmUrl from "@wllama/wllama/esm/single-thread/wllama.wasm?url";
9
+ import multiThreadWllamaJsUrl from "@wllama/wllama/esm/multi-thread/wllama.js?url";
10
+ import multiThreadWllamaWasmUrl from "@wllama/wllama/esm/multi-thread/wllama.wasm?url";
11
+ import multiThreadWllamaWorkerMjsUrl from "@wllama/wllama/esm/multi-thread/wllama.worker.mjs?url";
12
+
13
+ let wllama: Wllama | undefined;
14
+
15
+ export async function initializeWllama(config: {
16
+ modelUrl: string;
17
+ modelConfig?: LoadModelConfig;
18
+ }) {
19
+ const wllamaConfigPaths: AssetsPathConfig = {
20
+ "single-thread/wllama.js": singleThreadWllamaJsUrl,
21
+ "single-thread/wllama.wasm": singleThreadWllamaWasmUrl,
22
+ "multi-thread/wllama.js": multiThreadWllamaJsUrl,
23
+ "multi-thread/wllama.wasm": multiThreadWllamaWasmUrl,
24
+ "multi-thread/wllama.worker.mjs": multiThreadWllamaWorkerMjsUrl,
25
+ };
26
+
27
+ wllama = new Wllama(wllamaConfigPaths);
28
+
29
+ return wllama.loadModelFromUrl(config.modelUrl, config.modelConfig ?? {});
30
+ }
31
+
32
+ export async function runCompletion(config: {
33
+ prompt: string;
34
+ nPredict?: number;
35
+ sampling?: SamplingConfig;
36
+ onNewToken: (token: number, piece: Uint8Array, currentText: string) => void;
37
+ }) {
38
+ if (!wllama) throw new Error("Wllama is not initialized.");
39
+
40
+ return wllama.createCompletion(config.prompt, {
41
+ nPredict: config.nPredict,
42
+ sampling: config.sampling,
43
+ onNewToken: config.onNewToken,
44
+ });
45
+ }
46
+
47
+ export async function exitWllama() {
48
+ if (!wllama) throw new Error("Wllama is not initialized.");
49
+
50
+ await wllama.exit();
51
+
52
+ wllama = undefined;
53
+ }
client/public/query-suggestions.json ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "What's the distance between Earth and the Moon?",
3
+ "Is there life on Mars?",
4
+ "What is the speed of light?",
5
+ "Can I improve my memory without medication?",
6
+ "What's the psychology behind color preference in marketing?",
7
+ "Is it possible to learn multiple languages simultaneously?",
8
+ "How can I grow taller as an adult?",
9
+ "Explain quantum computing like I'm five",
10
+ "What are some sustainable fashion brands?",
11
+ "What's the connection between music and mental health?",
12
+ "Can meditation reduce stress and anxiety?",
13
+ "Are there any DIY tricks for cleaning oven stains?",
14
+ "How to make a good cup of coffee?",
15
+ "How do I train for a marathon without joining a gym?",
16
+ "What are some responsible travel tips?",
17
+ "How does a solar eclipse occur?",
18
+ "Do we burn calories while sleeping?",
19
+ "What are the best books to improve coding skills?",
20
+ "How can I improve my public speaking anxiety?",
21
+ "Can you recommend a good online course for digital art?",
22
+ "How do plants communicate with each other?",
23
+ "Can you explain the concept of time dilation in special relativity?",
24
+ "What are some tips for improving indoor air quality?",
25
+ "What are some vegan-friendly sources of protein?",
26
+ "How do I organize a small closet efficiently?",
27
+ "What is the life cycle of a butterfly?",
28
+ "Alternatives to an alarm clock",
29
+ "How to make a good first impression?",
30
+ "Are there any apps that can improve my sleep quality?",
31
+ "What's the difference between mindfulness and meditation?",
32
+ "Can practicing gratitude increase happiness?",
33
+ "How do I troubleshoot a slow internet connection?",
34
+ "What is the origin of the phrase \"break a leg\" in theater?",
35
+ "How can I improve my golf swing without a coach?",
36
+ "Can you recommend a good podcast on personal finance?",
37
+ "The history of the internet",
38
+ "What's the best way to care for aloe vera plant?",
39
+ "How does the brain create new memories?",
40
+ "Can meditation help with anxiety?",
41
+ "What are some eco-friendly alternatives to plastic wrap?",
42
+ "How do I maintain a healthy work-life balance?",
43
+ "What's the difference between a vegan and vegetarian diet?",
44
+ "Can listening to classical music help improve concentration?",
45
+ "How can I lose weight without exercising?",
46
+ "What's the significance of the Mayan calendar?",
47
+ "Can you explain the science behind dreams?",
48
+ "How do I build a successful online community?",
49
+ "What are some low-carb dinner options for vegetarians?",
50
+ "How do you properly care for a pet hamster?",
51
+ "Is it possible to grow crops in space?",
52
+ "How does the human body process caffeine?",
53
+ "Are there any benefits of taking cold showers first thing in the morning?",
54
+ "What are some creative ways to use old books?",
55
+ "Can you recommend a good online course for learning photography?",
56
+ "How do I manage stress during a busy work schedule?",
57
+ "What's the history behind the Olympic Games?",
58
+ "How does exercise affect mental health?",
59
+ "What are some healthy breakfast options on the go?",
60
+ "Are there any apps to track your daily water intake?",
61
+ "How do I start a vegetable garden on my small balcony?",
62
+ "Can you explain the concept of a black hole?",
63
+ "How can I improve my typing speed without lessons?",
64
+ "How do plants respond to different temperatures?",
65
+ "Can you recommend a good podcast series on artificial intelligence?",
66
+ "What's the difference between a comet and an asteroid?",
67
+ "Is it possible to train for a marathon without running long distances in the beginning?",
68
+ "What are some eco-friendly alternatives to plastic containers?",
69
+ "Can you suggest a good meditation technique for beginners?",
70
+ "How can I improve my public speaking without fear?",
71
+ "What's the science behind laughter and its health benefits?",
72
+ "How to make a mobile app?",
73
+ "Tips for a healthier lifestyle",
74
+ "Can you recommend a good online platform to learn graphic design?",
75
+ "How do I maintain good posture while working from home?",
76
+ "Are there any apps for tracking daily exercise routines?",
77
+ "What's the difference between a power nap and a regular sleep?",
78
+ "Can you explain the concept of blockchain technology?",
79
+ "What are the benefits of learning a new language?",
80
+ "How do I stay motivated to learn a new skill?",
81
+ "What are some tips for reducing screen time before bed?",
82
+ "Are there any apps to help with time management?",
83
+ "Can you recommend a good online course for learning Spanish?",
84
+ "How do I start a small business?",
85
+ "Is it possible to grow vegetables in a small apartment without a balcony?",
86
+ "How can I improve my singing voice at home?",
87
+ "What are the health benefits of yoga?",
88
+ "How to start a successful blog",
89
+ "What's the difference between a crocodile and an alligator?",
90
+ "Can you explain the concept of virtual reality technology?",
91
+ "What are some tips for dealing with a difficult team member at work?",
92
+ "Are there any apps to help learn new programming languages?",
93
+ "List of healthy snacks for work",
94
+ "Writing a resume that stands out",
95
+ "Elaborate on the concept of the butterfly effect",
96
+ "Getting started with investing in the stock market",
97
+ "How to make a good first impression in a job interview",
98
+ "How do we know all the money the government is getting from bank settlements is going back to the people?",
99
+ "What are good and bad sides of manual and automatic drive gear?",
100
+ "How do muscles grow?",
101
+ "What are some tips for improving my sleep quality?",
102
+ "Why, when intoxicated, does it feel like everything is spinning when you close your eyes but stops spinning when you open them?",
103
+ "Why are some fish bones edible, and others are not?",
104
+ "What is different in the brain chemistry that distinguishes thinking about moving my arm and moving it?",
105
+ "Why are the things that taste the best bad for us?",
106
+ "Why do you see weird colors when you press your eyes?",
107
+ "What classifies an island as an island? Aren't all continents etc essentially large islands?",
108
+ "Why do some people like getting drunk?",
109
+ "What happens when a \"too-big-to-fail\" bank goes bankrupt?",
110
+ "Why does a beer on tap almost always taste better than it does from a bottle?",
111
+ "Is it possible to build up an immunity to poisons both naturally occurring and man-made?",
112
+ "How do devices know the amount of charge left in a battery?",
113
+ "Why are my muscles sore after jumping in cold water?",
114
+ "Why do we like watching the same TV show or movie over and over again?",
115
+ "Why did The Beatles break up?",
116
+ "Why do phones not require cooling vents but other small appliances do?",
117
+ "If the inside of my microwave is made of metal, why is it bad to put metallic objects in it?",
118
+ "Why do we lack the instincts our ancestors had? (e.g. telling you which foods are poisonous)",
119
+ "How does bug spray work?",
120
+ "Why is it when you rewind VHS tapes they lose their quality over time?",
121
+ "If the ozone layer is made up of O3, why are we not producing some of it ourselves and pumping more of it into the atmosphere to fix the problem faster?",
122
+ "Is it possible to be a 'person without a country'?",
123
+ "How do the grooves on a record/LP recreate the sound of a full orchestra?",
124
+ "Why doesn't it rain salt water?",
125
+ "What is a MAC Address?",
126
+ "What happens to your brain when you space out?",
127
+ "Where do bugs go in the wintertime?",
128
+ "Why can't water catch on fire?",
129
+ "Besides being catchy, why do songs get stuck in our heads?",
130
+ "How does Wi-Fi on an airplane work?",
131
+ "How does honey never expire?",
132
+ "Why do certain people faint or feel weak at the sight of blood?",
133
+ "How do CD players read CDs?",
134
+ "Why do you sink in quicksand?",
135
+ "Why do we gasp when we're surprised/scared?",
136
+ "Why do things turn darker as they burn?",
137
+ "What exactly is torque?",
138
+ "Why is there a demand for high frames per second in video games, but it's ok for movies to stick to 24 FPS? Is there a visible difference between the two mediums?",
139
+ "Why is IP tracing so inaccurate?",
140
+ "Why is it hard to breathe when the water temperature is ice-cold in the shower?",
141
+ "Could children be taught to be ambidextrous?",
142
+ "How do barcodes work? Are they all different?",
143
+ "Why do our voices sound so different than what we think they sound like?",
144
+ "Why turbulence on an airplane, even the bigger bumps, aren't something to worry about while flying on an airplane?",
145
+ "How does the rule of three work?",
146
+ "How do passports work?",
147
+ "What's the difference between a carbohydrate and a hydrocarbon?",
148
+ "How does Google Maps suggest the same quicker route to everyone?",
149
+ "Why do we get bored?",
150
+ "How does movement in VR work?",
151
+ "Why is it easier to wash dishes with hot water?",
152
+ "Why does cabin pressure change during a flight?",
153
+ "In economics, how does inflation work?",
154
+ "Why do some countries require renunciation of previous citizenship if you obtain a new one?",
155
+ "How to turn an extra Wireless Router into a Wireless Extender?",
156
+ "Why do we tear up when we yawn?",
157
+ "How do radio waves transmit data?",
158
+ "How does transposition work in music?",
159
+ "Do we have enough food to feed everyone in the world?",
160
+ "If thousands of letters are in the Chinese alphabet, how can they have computer keyboards? How do they type?",
161
+ "How is snow formed?",
162
+ "How do film actors do two films at once?",
163
+ "Why do we forget most of our dreams after waking up?",
164
+ "Why is it so hard to artificially replenish the ozone layer?",
165
+ "Why are colossal (e.g. Godzilla) things depicted as moving so slowly?",
166
+ "What is the purpose of the spirals painted on airplane engines?",
167
+ "How do gas masks work?"
168
+ ]
client/types.d.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ /// <reference types="vite/client" />
2
+
3
+ declare const VITE_SEARCH_TOKEN: string;
docker-compose.production.yml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ production-server:
3
+ environment:
4
+ - HOST=${HOST:-0.0.0.0}
5
+ - PORT=${PORT:-7860}
6
+ - BASIC_SSL=${BASIC_SSL:-false}
7
+ ports:
8
+ - "${PORT:-7860}:7860"
9
+ build:
10
+ dockerfile: Dockerfile
11
+ context: .
docker-compose.yml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ development-server:
3
+ environment:
4
+ - HOST=${HOST:-0.0.0.0}
5
+ - PORT=${PORT:-7860}
6
+ - BASIC_SSL=${BASIC_SSL:-false}
7
+ - HMR_PORT=${HMR_PORT:-7861}
8
+ ports:
9
+ - "${PORT:-7860}:7860"
10
+ - "${HMR_PORT:-7861}:7861"
11
+ build:
12
+ dockerfile: Dockerfile
13
+ context: .
14
+ volumes:
15
+ - .:/home/user/app/
16
+ command:
17
+ [
18
+ "/usr/local/searxng/dockerfiles/docker-entrypoint.sh -f & npm install && npm run dev",
19
+ ]
eslint.config.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import globals from "globals";
2
+ import pluginJs from "@eslint/js";
3
+ import pluginTs from "typescript-eslint";
4
+
5
+ export default [
6
+ { ignores: ["client/dist"] },
7
+ { languageOptions: { globals: globals.browser } },
8
+ pluginJs.configs.recommended,
9
+ ...pluginTs.configs.recommended,
10
+ ];
hf-space-config.yml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ title: MiniSearch
2
+ emoji: 👌🔍
3
+ colorFrom: yellow
4
+ colorTo: yellow
5
+ sdk: docker
6
+ header: mini
7
+ short_description: Minimalist web-searching app with browser-based AI assistant
8
+ pinned: true
9
+ custom_headers:
10
+ cross-origin-embedder-policy: require-corp
11
+ cross-origin-opener-policy: same-origin
12
+ cross-origin-resource-policy: cross-origin
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "mini-search",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "scripts": {
8
+ "start": "vite preview",
9
+ "build": "tsc && vite build",
10
+ "dev": "vite",
11
+ "lint": "prettier --check . && eslint --fix client",
12
+ "format": "prettier --write ."
13
+ },
14
+ "dependencies": {
15
+ "@energetic-ai/core": "^0.2.0",
16
+ "@energetic-ai/embeddings": "^0.2.0",
17
+ "@energetic-ai/model-embeddings-en": "^0.2.0",
18
+ "@mlc-ai/web-llm": "0.2.35",
19
+ "@ratchet-ml/ratchet-web": "^0.4.0",
20
+ "@wllama/wllama": "^1.4.2",
21
+ "create-pubsub": "^1.6.3",
22
+ "html-to-text": "^9.0.5",
23
+ "inter-ui": "^4.0.2",
24
+ "keyword-extractor": "^0.0.28",
25
+ "markdown-to-jsx": "^7.3.2",
26
+ "mobile-detect": "^1.4.5",
27
+ "node-fetch": "^3.3.2",
28
+ "prettier": "^3.0.3",
29
+ "rate-limiter-flexible": "^5.0.2",
30
+ "react": "^18.2.0",
31
+ "react-dom": "^18.2.0",
32
+ "react-hot-toast": "^2.4.1",
33
+ "react-textarea-autosize": "^8.5.3",
34
+ "react-tooltip": "^5.21.6",
35
+ "temp-dir": "^3.0.0",
36
+ "water.css": "^2.1.1"
37
+ },
38
+ "devDependencies": {
39
+ "@eslint/js": "^9.0.0",
40
+ "@types/html-to-text": "^9.0.4",
41
+ "@types/node": "^20.12.7",
42
+ "@types/react": "^18.2.15",
43
+ "@types/react-dom": "^18.2.7",
44
+ "@vitejs/plugin-basic-ssl": "^1.1.0",
45
+ "@vitejs/plugin-react": "^4.0.3",
46
+ "eslint": "^9.0.0",
47
+ "globals": "^15.0.0",
48
+ "typescript": "^5.0.2",
49
+ "typescript-eslint": "^7.6.0",
50
+ "vite": "^5.2.9"
51
+ }
52
+ }
renovate.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
+ "extends": ["local>felladrin/.github:renovate-config"]
4
+ }
tsconfig.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "useDefineForClassFields": true,
5
+ "module": "ESNext",
6
+ "skipLibCheck": true,
7
+ "moduleResolution": "bundler",
8
+ "allowImportingTsExtensions": true,
9
+ "resolveJsonModule": true,
10
+ "isolatedModules": true,
11
+ "noEmit": true,
12
+ "jsx": "react-jsx",
13
+ "strict": true,
14
+ "noUnusedLocals": true,
15
+ "noUnusedParameters": true,
16
+ "noFallthroughCasesInSwitch": true
17
+ },
18
+ "include": ["client"],
19
+ "references": [{ "path": "./tsconfig.node.json" }]
20
+ }
tsconfig.node.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowSyntheticDefaultImports": true
8
+ },
9
+ "include": ["vite.config.ts"]
10
+ }
vite.config.ts ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PreviewServer, ViteDevServer, defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import basicSSL from "@vitejs/plugin-basic-ssl";
4
+ import fetch from "node-fetch";
5
+ import { RateLimiterMemory } from "rate-limiter-flexible";
6
+ import { writeFileSync, readFileSync, existsSync } from "node:fs";
7
+ import temporaryDirectory from "temp-dir";
8
+ import path from "node:path";
9
+ import {
10
+ initModel,
11
+ distance as calculateSimilarity,
12
+ EmbeddingsModel,
13
+ } from "@energetic-ai/embeddings";
14
+ import { modelSource as embeddingModel } from "@energetic-ai/model-embeddings-en";
15
+
16
+ const serverStartTime = new Date().getTime();
17
+ let searchesSinceLastRestart = 0;
18
+
19
+ export default defineConfig(({ command }) => {
20
+ if (command === "build") regenerateSearchToken();
21
+
22
+ return {
23
+ root: "./client",
24
+ define: {
25
+ VITE_SEARCH_TOKEN: JSON.stringify(getSearchToken()),
26
+ },
27
+ server: {
28
+ host: process.env.HOST,
29
+ port: process.env.PORT ? Number(process.env.PORT) : undefined,
30
+ hmr: {
31
+ port: process.env.HMR_PORT ? Number(process.env.HMR_PORT) : undefined,
32
+ },
33
+ },
34
+ preview: {
35
+ host: process.env.HOST,
36
+ port: process.env.PORT ? Number(process.env.PORT) : undefined,
37
+ },
38
+ build: {
39
+ target: "esnext",
40
+ },
41
+ plugins: [
42
+ process.env.BASIC_SSL === "true" ? basicSSL() : undefined,
43
+ react(),
44
+ {
45
+ name: "configure-server-cross-origin-isolation",
46
+ configureServer: crossOriginServerHook,
47
+ configurePreviewServer: crossOriginServerHook,
48
+ },
49
+ {
50
+ name: "configure-server-search-endpoint",
51
+ configureServer: searchEndpointServerHook,
52
+ configurePreviewServer: searchEndpointServerHook,
53
+ },
54
+ {
55
+ name: "configure-server-status-endpoint",
56
+ configureServer: statusEndpointServerHook,
57
+ configurePreviewServer: statusEndpointServerHook,
58
+ },
59
+ {
60
+ name: "configure-server-cache",
61
+ configurePreviewServer: cacheServerHook,
62
+ },
63
+ ],
64
+ };
65
+ });
66
+
67
+ function crossOriginServerHook<T extends ViteDevServer | PreviewServer>(
68
+ server: T,
69
+ ) {
70
+ server.middlewares.use((_, response, next) => {
71
+ /** Server headers for cross origin isolation, which enable clients to use `SharedArrayBuffer` on the Browser. */
72
+ const crossOriginIsolationHeaders: { key: string; value: string }[] = [
73
+ {
74
+ key: "Cross-Origin-Embedder-Policy",
75
+ value: "require-corp",
76
+ },
77
+ {
78
+ key: "Cross-Origin-Opener-Policy",
79
+ value: "same-origin",
80
+ },
81
+ {
82
+ key: "Cross-Origin-Resource-Policy",
83
+ value: "cross-origin",
84
+ },
85
+ ];
86
+
87
+ crossOriginIsolationHeaders.forEach(({ key, value }) => {
88
+ response.setHeader(key, value);
89
+ });
90
+
91
+ next();
92
+ });
93
+ }
94
+
95
+ function statusEndpointServerHook<T extends ViteDevServer | PreviewServer>(
96
+ server: T,
97
+ ) {
98
+ server.middlewares.use(async (request, response, next) => {
99
+ if (!request.url.startsWith("/status")) return next();
100
+
101
+ const secondsSinceLastRestart = Math.floor(
102
+ (new Date().getTime() - serverStartTime) / 1000,
103
+ );
104
+
105
+ response.end(
106
+ JSON.stringify({
107
+ secondsSinceLastRestart,
108
+ searchesSinceLastRestart,
109
+ searchesPerSecond: searchesSinceLastRestart / secondsSinceLastRestart,
110
+ }),
111
+ );
112
+ });
113
+ }
114
+
115
+ function searchEndpointServerHook<T extends ViteDevServer | PreviewServer>(
116
+ server: T,
117
+ ) {
118
+ const rateLimiter = new RateLimiterMemory({
119
+ points: 2,
120
+ duration: 10,
121
+ });
122
+
123
+ server.middlewares.use(async (request, response, next) => {
124
+ if (!request.url.startsWith("/search")) return next();
125
+
126
+ const { searchParams } = new URL(
127
+ request.url,
128
+ `http://${request.headers.host}`,
129
+ );
130
+
131
+ const token = searchParams.get("token");
132
+
133
+ if (!token || token !== getSearchToken()) {
134
+ response.statusCode = 401;
135
+ response.end("Unauthorized.");
136
+ return;
137
+ }
138
+
139
+ const query = searchParams.get("q");
140
+
141
+ if (!query) {
142
+ response.statusCode = 400;
143
+ response.end("Missing the query parameter.");
144
+ return;
145
+ }
146
+
147
+ const limitParam = searchParams.get("limit");
148
+
149
+ const limit =
150
+ limitParam && Number(limitParam) > 0 ? Number(limitParam) : undefined;
151
+
152
+ try {
153
+ const remoteAddress = (
154
+ (request.headers["x-forwarded-for"] as string) ||
155
+ request.socket.remoteAddress ||
156
+ "unknown"
157
+ )
158
+ .split(",")[0]
159
+ .trim();
160
+
161
+ await rateLimiter.consume(remoteAddress);
162
+ } catch (error) {
163
+ response.statusCode = 429;
164
+ response.end("Too many requests.");
165
+ return;
166
+ }
167
+
168
+ const searchResults = await fetchSearXNG(query, limit);
169
+
170
+ searchesSinceLastRestart++;
171
+
172
+ if (searchResults.length === 0) {
173
+ response.end(JSON.stringify([]));
174
+ return;
175
+ }
176
+
177
+ try {
178
+ response.end(
179
+ JSON.stringify(await rankSearchResults(query, searchResults)),
180
+ );
181
+ } catch (error) {
182
+ console.error("Error ranking search results:", error);
183
+ response.end(JSON.stringify(searchResults));
184
+ }
185
+ });
186
+ }
187
+
188
+ function cacheServerHook<T extends ViteDevServer | PreviewServer>(server: T) {
189
+ server.middlewares.use(async (request, response, next) => {
190
+ if (
191
+ request.url === "/" ||
192
+ request.url.startsWith("/?") ||
193
+ request.url.endsWith(".html")
194
+ ) {
195
+ response.setHeader("Cache-Control", "no-cache");
196
+ } else {
197
+ response.setHeader("Cache-Control", "public, max-age=86400");
198
+ }
199
+
200
+ next();
201
+ });
202
+ }
203
+
204
+ async function fetchSearXNG(query: string, limit?: number) {
205
+ try {
206
+ const url = new URL("http://127.0.0.1:8080/search");
207
+
208
+ url.search = new URLSearchParams({
209
+ q: query,
210
+ language: "auto",
211
+ safesearch: "0",
212
+ format: "json",
213
+ }).toString();
214
+
215
+ const response = await fetch(url);
216
+
217
+ let { results } = (await response.json()) as {
218
+ results: { url: string; title: string; content: string }[];
219
+ };
220
+
221
+ const searchResults: [title: string, content: string, url: string][] = [];
222
+
223
+ if (results) {
224
+ if (limit && limit > 0) {
225
+ results = results.slice(0, limit);
226
+ }
227
+
228
+ const uniqueUrls = new Set<string>();
229
+
230
+ for (const result of results) {
231
+ if (!result.content || uniqueUrls.has(result.url)) continue;
232
+
233
+ const stripHtmlTags = (str: string) => str.replace(/<[^>]*>?/gm, "");
234
+
235
+ const content = stripHtmlTags(result.content).trim();
236
+
237
+ if (content === "") continue;
238
+
239
+ const title = stripHtmlTags(result.title);
240
+
241
+ const url = result.url;
242
+
243
+ searchResults.push([title, content, url]);
244
+
245
+ uniqueUrls.add(url);
246
+ }
247
+ }
248
+
249
+ return searchResults;
250
+ } catch (e) {
251
+ console.error(e);
252
+ return [];
253
+ }
254
+ }
255
+
256
+ function getSearchTokenFilePath() {
257
+ return path.resolve(temporaryDirectory, "minisearch-token");
258
+ }
259
+
260
+ function regenerateSearchToken() {
261
+ const newToken = Math.random().toString(36).substring(2);
262
+ writeFileSync(getSearchTokenFilePath(), newToken);
263
+ }
264
+
265
+ function getSearchToken() {
266
+ if (!existsSync(getSearchTokenFilePath())) regenerateSearchToken();
267
+ return readFileSync(getSearchTokenFilePath(), "utf8");
268
+ }
269
+
270
+ let embeddingModelInstance: EmbeddingsModel | undefined;
271
+
272
+ async function getSimilarityScores(query: string, documents: string[]) {
273
+ if (!embeddingModelInstance) {
274
+ embeddingModelInstance = await initModel(embeddingModel);
275
+ }
276
+
277
+ const [queryEmbedding] = await embeddingModelInstance.embed([query]);
278
+
279
+ const documentsEmbeddings = await embeddingModelInstance.embed(documents);
280
+
281
+ return documentsEmbeddings.map((documentEmbedding) =>
282
+ calculateSimilarity(queryEmbedding, documentEmbedding),
283
+ );
284
+ }
285
+
286
+ async function rankSearchResults(
287
+ query: string,
288
+ searchResults: [title: string, content: string, url: string][],
289
+ ) {
290
+ const scores = await getSimilarityScores(
291
+ query.toLocaleLowerCase(),
292
+ searchResults.map(([title, snippet, url]) =>
293
+ `${title}\n${url}\n${snippet}`.toLocaleLowerCase(),
294
+ ),
295
+ );
296
+
297
+ const searchResultToScoreMap: Map<(typeof searchResults)[0], number> =
298
+ new Map();
299
+
300
+ scores.map((score, index) =>
301
+ searchResultToScoreMap.set(searchResults[index], score ?? 0),
302
+ );
303
+
304
+ return searchResults
305
+ .slice()
306
+ .sort(
307
+ (a, b) =>
308
+ (searchResultToScoreMap.get(b) ?? 0) -
309
+ (searchResultToScoreMap.get(a) ?? 0),
310
+ );
311
+ }