Upload 39 files
Browse files- .dockerignore +27 -0
- .editorconfig +7 -0
- .github/workflows/check-docker-container.yml +16 -0
- .github/workflows/code-quality.yml +18 -0
- .github/workflows/huggingface-sync.yml +22 -0
- .github/workflows/publish-docker-image.yml +37 -0
- .gitignore +3 -0
- .npmrc +1 -0
- Dockerfile +22 -0
- client/components/App.tsx +51 -0
- client/components/SearchForm.tsx +119 -0
- client/components/SearchResultsList.tsx +81 -0
- client/components/SettingsButton.tsx +57 -0
- client/components/SettingsForm.tsx +109 -0
- client/index.css +21 -0
- client/index.html +40 -0
- client/index.tsx +6 -0
- client/modules/mobileDetection.ts +4 -0
- client/modules/pubSub.ts +71 -0
- client/modules/querySuggestions.ts +28 -0
- client/modules/ratchet.ts +37 -0
- client/modules/search.ts +28 -0
- client/modules/textGeneration.ts +526 -0
- client/modules/urlParams.ts +4 -0
- client/modules/webGpu.ts +17 -0
- client/modules/webLlmWorker.ts +7 -0
- client/modules/wllama.ts +53 -0
- client/public/query-suggestions.json +168 -0
- client/types.d.ts +3 -0
- docker-compose.production.yml +11 -0
- docker-compose.yml +19 -0
- eslint.config.js +10 -0
- hf-space-config.yml +12 -0
- package-lock.json +0 -0
- package.json +52 -0
- renovate.json +4 -0
- tsconfig.json +20 -0
- tsconfig.node.json +10 -0
- vite.config.ts +311 -0
.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 |
+
}
|