Spaces:
Running
Running
github-actions[bot]
commited on
Commit
β’
f152ae2
0
Parent(s):
Sync to HuggingFace Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. Β
See raw diff
- .dockerignore +25 -0
- .editorconfig +7 -0
- .env.example +32 -0
- .github/workflows/ai-pr-summarizer/model.txt +1 -0
- .github/workflows/ai-pr-summarizer/ollama-version.txt +1 -0
- .github/workflows/ai-pr-summarizer/prompt.txt +29 -0
- .github/workflows/on-pull-request-to-main.yml +29 -0
- .github/workflows/on-push-to-main.yml +50 -0
- .github/workflows/reusable-test-lint-ping.yml +25 -0
- .github/workflows/update-searxng-docker-image.yml +44 -0
- .gitignore +8 -0
- .npmrc +1 -0
- Dockerfile +82 -0
- README.md +146 -0
- biome.json +30 -0
- client/components/AiResponse/AiModelDownloadAllowanceContent.tsx +62 -0
- client/components/AiResponse/AiResponseContent.tsx +180 -0
- client/components/AiResponse/AiResponseSection.tsx +92 -0
- client/components/AiResponse/ChatInterface.tsx +208 -0
- client/components/AiResponse/FormattedMarkdown.tsx +38 -0
- client/components/AiResponse/LoadingModelContent.tsx +40 -0
- client/components/AiResponse/PreparingContent.tsx +29 -0
- client/components/AiResponse/WebLlmModelSelect.tsx +81 -0
- client/components/AiResponse/WllamaModelSelect.tsx +42 -0
- client/components/App/App.tsx +97 -0
- client/components/Logs/LogsModal.tsx +101 -0
- client/components/Logs/ShowLogsButton.tsx +42 -0
- client/components/Pages/AccessPage.tsx +61 -0
- client/components/Pages/Main/MainPage.tsx +74 -0
- client/components/Pages/Main/Menu/AISettingsForm.tsx +368 -0
- client/components/Pages/Main/Menu/ActionsForm.tsx +18 -0
- client/components/Pages/Main/Menu/ClearDataButton.tsx +59 -0
- client/components/Pages/Main/Menu/InterfaceSettingsForm.tsx +54 -0
- client/components/Pages/Main/Menu/MenuButton.tsx +53 -0
- client/components/Pages/Main/Menu/MenuDrawer.tsx +102 -0
- client/components/Search/Form/SearchForm.tsx +137 -0
- client/components/Search/Results/Graphical/ImageResultsList.tsx +117 -0
- client/components/Search/Results/SearchResultsSection.tsx +132 -0
- client/components/Search/Results/Textual/SearchResultsList.tsx +85 -0
- client/index.html +36 -0
- client/index.tsx +9 -0
- client/modules/accessKey.ts +95 -0
- client/modules/logEntries.ts +20 -0
- client/modules/openai.ts +16 -0
- client/modules/parentWindow.ts +5 -0
- client/modules/pubSub.ts +93 -0
- client/modules/querySuggestions.ts +31 -0
- client/modules/search.ts +159 -0
- client/modules/searchTokenHash.ts +41 -0
- client/modules/settings.ts +49 -0
.dockerignore
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
|
.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
|
.env.example
ADDED
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# A comma-separated list of access keys. Example: `ACCESS_KEYS="ABC123,JUD71F,HUWE3"`. Leave blank for unrestricted access.
|
2 |
+
ACCESS_KEYS=""
|
3 |
+
|
4 |
+
# The timeout in hours for access key validation. Set to 0 to require validation on every page load.
|
5 |
+
ACCESS_KEY_TIMEOUT_HOURS="24"
|
6 |
+
|
7 |
+
# The default model ID for WebLLM with F16 shaders.
|
8 |
+
WEBLLM_DEFAULT_F16_MODEL_ID="SmolLM2-360M-Instruct-q0f16-MLC"
|
9 |
+
|
10 |
+
# The default model ID for WebLLM with F32 shaders.
|
11 |
+
WEBLLM_DEFAULT_F32_MODEL_ID="SmolLM2-360M-Instruct-q0f32-MLC"
|
12 |
+
|
13 |
+
# The default model ID for Wllama.
|
14 |
+
WLLAMA_DEFAULT_MODEL_ID="smollm2-360m"
|
15 |
+
|
16 |
+
# The base URL for the internal OpenAI compatible API. Example: `INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL="https://api.openai.com/v1"`. Leave blank to disable internal OpenAI compatible API.
|
17 |
+
INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL=""
|
18 |
+
|
19 |
+
# The access key for the internal OpenAI compatible API.
|
20 |
+
INTERNAL_OPENAI_COMPATIBLE_API_KEY=""
|
21 |
+
|
22 |
+
# The model for the internal OpenAI compatible API.
|
23 |
+
INTERNAL_OPENAI_COMPATIBLE_API_MODEL=""
|
24 |
+
|
25 |
+
# The name of the internal OpenAI compatible API, displayed in the UI.
|
26 |
+
INTERNAL_OPENAI_COMPATIBLE_API_NAME="Internal API"
|
27 |
+
|
28 |
+
# The type of inference to use by default. The possible values are:
|
29 |
+
# "browser" -> In the browser (Private)
|
30 |
+
# "openai" -> Remote Server (API)
|
31 |
+
# "internal" -> $INTERNAL_OPENAI_COMPATIBLE_API_NAME
|
32 |
+
DEFAULT_INFERENCE_TYPE="browser"
|
.github/workflows/ai-pr-summarizer/model.txt
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
qwen2.5-coder:7b-instruct-q8_0
|
.github/workflows/ai-pr-summarizer/ollama-version.txt
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
0.3.14
|
.github/workflows/ai-pr-summarizer/prompt.txt
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
You are an expert code reviewer analyzing git diff output. Provide a clear, actionable review summary focusing on what matters most to the development team:
|
2 |
+
|
3 |
+
1. Changes Overview. For example:
|
4 |
+
- Key functionality modifications
|
5 |
+
- API/interface changes
|
6 |
+
- Data structure updates
|
7 |
+
- New dependencies
|
8 |
+
|
9 |
+
2. Technical Impact. For example:
|
10 |
+
- Logic changes and their implications
|
11 |
+
- Performance considerations
|
12 |
+
- Security implications
|
13 |
+
- Breaking changes or migration requirements
|
14 |
+
|
15 |
+
3. Quality Assessment. For example:
|
16 |
+
- Test coverage adequacy
|
17 |
+
- Error handling completeness
|
18 |
+
- Code complexity concerns
|
19 |
+
- Documentation updates needed
|
20 |
+
|
21 |
+
4. Actionable Recommendations. For example:
|
22 |
+
- Critical issues that must be addressed
|
23 |
+
- Suggested improvements
|
24 |
+
- Potential refactoring opportunities
|
25 |
+
|
26 |
+
5. Suggested Commit Message.
|
27 |
+
- Provide a one-sentence commit message summarizing the changes
|
28 |
+
|
29 |
+
Here is the git diff output for review:
|
.github/workflows/on-pull-request-to-main.yml
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
name: On Pull Request To Main
|
2 |
+
on:
|
3 |
+
pull_request:
|
4 |
+
types: [opened, synchronize, reopened]
|
5 |
+
branches: ["main"]
|
6 |
+
jobs:
|
7 |
+
test-lint-ping:
|
8 |
+
uses: ./.github/workflows/reusable-test-lint-ping.yml
|
9 |
+
ai-pr-summarizer:
|
10 |
+
needs: [test-lint-ping]
|
11 |
+
runs-on: ubuntu-latest
|
12 |
+
name: AI PR Summarizer
|
13 |
+
permissions:
|
14 |
+
pull-requests: write
|
15 |
+
contents: read
|
16 |
+
steps:
|
17 |
+
- name: Checkout Repository
|
18 |
+
uses: actions/checkout@v4
|
19 |
+
- name: Summarize PR with AI
|
20 |
+
uses: behrouz-rad/ai-pr-summarizer@v1
|
21 |
+
with:
|
22 |
+
llm-model: 'qwen2.5-coder:7b-instruct-q8_0'
|
23 |
+
prompt-file: ./.github/workflows/ai-pr-summarizer/prompt.txt
|
24 |
+
models-file: ./.github/workflows/ai-pr-summarizer/model.txt
|
25 |
+
version-file: ./.github/workflows/ai-pr-summarizer/ollama-version.txt
|
26 |
+
context-window: 16384
|
27 |
+
upload-changes: false
|
28 |
+
fail-on-error: false
|
29 |
+
token: ${{ secrets.GITHUB_TOKEN }}
|
.github/workflows/on-push-to-main.yml
ADDED
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
name: On Push To Master
|
2 |
+
on:
|
3 |
+
push:
|
4 |
+
branches: ["master"]
|
5 |
+
jobs:
|
6 |
+
test-lint-ping:
|
7 |
+
uses: ./.github/workflows/reusable-test-lint-ping.yml
|
8 |
+
build-and-push-image:
|
9 |
+
needs: [test-lint-ping]
|
10 |
+
name: Publish Docker image to Docker Hub
|
11 |
+
runs-on: ubuntu-latest
|
12 |
+
steps:
|
13 |
+
- name: Checkout repository
|
14 |
+
uses: actions/checkout@v4
|
15 |
+
- name: Login to DockerHub
|
16 |
+
if: github.ref == 'refs/heads/master'
|
17 |
+
uses: docker/login-action@v2
|
18 |
+
with:
|
19 |
+
username: jack20191124
|
20 |
+
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
21 |
+
- name: Push image to DockerHub
|
22 |
+
if: github.ref == 'refs/heads/master'
|
23 |
+
uses: docker/build-push-action@v3
|
24 |
+
with:
|
25 |
+
context: .
|
26 |
+
push: true
|
27 |
+
tags: jack20191124/mini-search:latest
|
28 |
+
- name: Push image Description
|
29 |
+
if: github.ref == 'refs/heads/master'
|
30 |
+
uses: peter-evans/dockerhub-description@v4
|
31 |
+
with:
|
32 |
+
username: jack20191124
|
33 |
+
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
34 |
+
repository: jack20191124/mini-search
|
35 |
+
readme-filepath: README.md
|
36 |
+
sync-to-hf:
|
37 |
+
needs: [test-lint-ping]
|
38 |
+
name: Sync to HuggingFace Spaces
|
39 |
+
runs-on: ubuntu-latest
|
40 |
+
steps:
|
41 |
+
- uses: actions/checkout@v4
|
42 |
+
with:
|
43 |
+
lfs: true
|
44 |
+
- uses: JacobLinCool/huggingface-sync@v1
|
45 |
+
with:
|
46 |
+
github: ${{ secrets.GITHUB_TOKEN }}
|
47 |
+
user: QubitPi
|
48 |
+
space: miniSearch
|
49 |
+
token: ${{ secrets.HF_TOKEN }}
|
50 |
+
configuration: "hf-space-config.yml"
|
.github/workflows/reusable-test-lint-ping.yml
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
on:
|
2 |
+
workflow_call:
|
3 |
+
jobs:
|
4 |
+
check-code-quality:
|
5 |
+
name: Check Code Quality
|
6 |
+
runs-on: ubuntu-latest
|
7 |
+
steps:
|
8 |
+
- uses: actions/checkout@v4
|
9 |
+
- uses: actions/setup-node@v4
|
10 |
+
with:
|
11 |
+
node-version: 20
|
12 |
+
cache: "npm"
|
13 |
+
- run: npm ci --ignore-scripts
|
14 |
+
- run: npm test
|
15 |
+
- run: npm run lint
|
16 |
+
check-docker-container:
|
17 |
+
needs: [check-code-quality]
|
18 |
+
name: Check Docker Container
|
19 |
+
runs-on: ubuntu-latest
|
20 |
+
steps:
|
21 |
+
- uses: actions/checkout@v4
|
22 |
+
- run: docker compose -f docker-compose.production.yml up -d
|
23 |
+
- name: Check if main page is available
|
24 |
+
run: until curl -s -o /dev/null -w "%{http_code}" localhost:7860 | grep 200; do sleep 1; done
|
25 |
+
- run: docker compose -f docker-compose.production.yml down
|
.github/workflows/update-searxng-docker-image.yml
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
name: Update SearXNG Docker Image
|
2 |
+
|
3 |
+
on:
|
4 |
+
schedule:
|
5 |
+
- cron: "0 14 * * *"
|
6 |
+
workflow_dispatch:
|
7 |
+
|
8 |
+
permissions:
|
9 |
+
contents: write
|
10 |
+
|
11 |
+
jobs:
|
12 |
+
update-searxng-image:
|
13 |
+
runs-on: ubuntu-latest
|
14 |
+
steps:
|
15 |
+
- name: Checkout code
|
16 |
+
uses: actions/checkout@v4
|
17 |
+
with:
|
18 |
+
token: ${{ secrets.GITHUB_TOKEN }}
|
19 |
+
|
20 |
+
- name: Get latest SearXNG image tag
|
21 |
+
id: get_latest_tag
|
22 |
+
run: |
|
23 |
+
LATEST_TAG=$(curl -s "https://hub.docker.com/v2/repositories/searxng/searxng/tags/?page_size=3&ordering=last_updated" | jq -r '.results[] | select(.name != "latest-build-cache" and .name != "latest") | .name' | head -n 1)
|
24 |
+
echo "LATEST_TAG=${LATEST_TAG}" >> $GITHUB_OUTPUT
|
25 |
+
|
26 |
+
- name: Update Dockerfile
|
27 |
+
run: |
|
28 |
+
sed -i 's|FROM searxng/searxng:.*|FROM searxng/searxng:${{ steps.get_latest_tag.outputs.LATEST_TAG }}|' Dockerfile
|
29 |
+
|
30 |
+
- name: Check for changes
|
31 |
+
id: git_status
|
32 |
+
run: |
|
33 |
+
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
34 |
+
|
35 |
+
- name: Commit and push if changed
|
36 |
+
if: steps.git_status.outputs.changes == 'true'
|
37 |
+
run: |
|
38 |
+
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
39 |
+
git config --local user.name "github-actions[bot]"
|
40 |
+
git add Dockerfile
|
41 |
+
git commit -m "Update SearXNG Docker image to tag ${{ steps.get_latest_tag.outputs.LATEST_TAG }}"
|
42 |
+
git push
|
43 |
+
env:
|
44 |
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
.gitignore
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
node_modules
|
2 |
+
.DS_Store
|
3 |
+
/client/dist
|
4 |
+
/server/models
|
5 |
+
.vscode
|
6 |
+
/vite-build-stats.html
|
7 |
+
.env
|
8 |
+
.idea/
|
.npmrc
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
legacy-peer-deps = true
|
Dockerfile
ADDED
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Use the SearXNG image as the base
|
2 |
+
FROM searxng/searxng:2024.11.1-b07c0ae39
|
3 |
+
|
4 |
+
# Set the default port to 7860 if not provided
|
5 |
+
ENV PORT=7860
|
6 |
+
|
7 |
+
# Expose the port specified by the PORT environment variable
|
8 |
+
EXPOSE $PORT
|
9 |
+
|
10 |
+
# Install necessary packages using Alpine's package manager
|
11 |
+
RUN apk add --update \
|
12 |
+
nodejs \
|
13 |
+
npm \
|
14 |
+
git \
|
15 |
+
build-base \
|
16 |
+
cmake \
|
17 |
+
ccache
|
18 |
+
|
19 |
+
# Set the SearXNG settings folder path
|
20 |
+
ARG SEARXNG_SETTINGS_FOLDER=/etc/searxng
|
21 |
+
|
22 |
+
# Modify SearXNG configuration:
|
23 |
+
# 1. Change output format from HTML to JSON
|
24 |
+
# 2. Remove user switching in the entrypoint script
|
25 |
+
# 3. Create and set permissions for the settings folder
|
26 |
+
RUN sed -i 's/- html/- json/' /usr/local/searxng/searx/settings.yml \
|
27 |
+
&& sed -i 's/su-exec searxng:searxng //' /usr/local/searxng/dockerfiles/docker-entrypoint.sh \
|
28 |
+
&& mkdir -p ${SEARXNG_SETTINGS_FOLDER} \
|
29 |
+
&& chmod 777 ${SEARXNG_SETTINGS_FOLDER}
|
30 |
+
|
31 |
+
# Set up user and directory structure
|
32 |
+
ARG USERNAME=user
|
33 |
+
ARG HOME_DIR=/home/${USERNAME}
|
34 |
+
ARG APP_DIR=${HOME_DIR}/app
|
35 |
+
|
36 |
+
# Create a non-root user and set up the application directory
|
37 |
+
RUN adduser -D -u 1000 ${USERNAME} \
|
38 |
+
&& mkdir -p ${APP_DIR} \
|
39 |
+
&& chown -R ${USERNAME}:${USERNAME} ${HOME_DIR}
|
40 |
+
|
41 |
+
# Switch to the non-root user
|
42 |
+
USER ${USERNAME}
|
43 |
+
|
44 |
+
# Set the working directory to the application directory
|
45 |
+
WORKDIR ${APP_DIR}
|
46 |
+
|
47 |
+
# Define environment variables that can be passed to the container during build.
|
48 |
+
# This approach allows for dynamic configuration without relying on a `.env` file,
|
49 |
+
# which might not be suitable for all deployment scenarios.
|
50 |
+
ARG ACCESS_KEYS
|
51 |
+
ARG ACCESS_KEY_TIMEOUT_HOURS
|
52 |
+
ARG WEBLLM_DEFAULT_F16_MODEL_ID
|
53 |
+
ARG WEBLLM_DEFAULT_F32_MODEL_ID
|
54 |
+
ARG WLLAMA_DEFAULT_MODEL_ID
|
55 |
+
ARG INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL
|
56 |
+
ARG INTERNAL_OPENAI_COMPATIBLE_API_KEY
|
57 |
+
ARG INTERNAL_OPENAI_COMPATIBLE_API_MODEL
|
58 |
+
ARG INTERNAL_OPENAI_COMPATIBLE_API_NAME
|
59 |
+
ARG DEFAULT_INFERENCE_TYPE
|
60 |
+
|
61 |
+
# Copy package.json, package-lock.json, and .npmrc files
|
62 |
+
COPY --chown=${USERNAME}:${USERNAME} ./package.json ./package.json
|
63 |
+
COPY --chown=${USERNAME}:${USERNAME} ./package-lock.json ./package-lock.json
|
64 |
+
COPY --chown=${USERNAME}:${USERNAME} ./.npmrc ./.npmrc
|
65 |
+
|
66 |
+
# Install Node.js dependencies
|
67 |
+
RUN npm ci
|
68 |
+
|
69 |
+
# Copy the rest of the application files
|
70 |
+
COPY --chown=${USERNAME}:${USERNAME} . .
|
71 |
+
|
72 |
+
# Configure Git to treat the app directory as safe
|
73 |
+
RUN git config --global --add safe.directory ${APP_DIR}
|
74 |
+
|
75 |
+
# Build the application
|
76 |
+
RUN npm run build
|
77 |
+
|
78 |
+
# Set the entrypoint to use a shell
|
79 |
+
ENTRYPOINT [ "/bin/sh", "-c" ]
|
80 |
+
|
81 |
+
# Run SearXNG in the background and start the Node.js application using PM2
|
82 |
+
CMD [ "(/usr/local/searxng/dockerfiles/docker-entrypoint.sh -f > /dev/null 2>&1) & (npx pm2 start ecosystem.config.cjs && npx pm2 logs production-server)" ]
|
README.md
ADDED
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
---
|
2 |
+
title: MiniSearch
|
3 |
+
emoji: ππ
|
4 |
+
colorFrom: yellow
|
5 |
+
colorTo: yellow
|
6 |
+
sdk: docker
|
7 |
+
short_description: Minimalist web-searching app with browser-based AI assistant
|
8 |
+
pinned: false
|
9 |
+
custom_headers:
|
10 |
+
cross-origin-embedder-policy: require-corp
|
11 |
+
cross-origin-opener-policy: same-origin
|
12 |
+
cross-origin-resource-policy: cross-origin
|
13 |
+
---
|
14 |
+
|
15 |
+
[![Hugging Face space badge]][Hugging Face space URL]
|
16 |
+
[![GitHub workflow status badge][GitHub workflow status badge]][GitHub workflow status URL]
|
17 |
+
[![Hugging Face sync status badge]][Hugging Face sync status URL]
|
18 |
+
[![Docker Hub][Docker Pulls Badge]][Docker Hub URL]
|
19 |
+
[![Apache License Badge]][Apache License, Version 2.0]
|
20 |
+
|
21 |
+
# MiniSearch
|
22 |
+
|
23 |
+
A minimalist web-searching app with an AI assistant that runs directly from your browser.
|
24 |
+
|
25 |
+
Live demo: https://huggingface.co/spaces/QubitPi/miniSearch
|
26 |
+
|
27 |
+
## Screenshot
|
28 |
+
|
29 |
+
![MiniSearch Screenshot](https://github.com/user-attachments/assets/f8d72a8e-a725-42e9-9358-e6ebade2acb2)
|
30 |
+
|
31 |
+
## Features
|
32 |
+
|
33 |
+
- **Privacy-focused**: [No tracking, no ads, no data collection](https://docs.searxng.org/own-instance.html#how-does-searxng-protect-privacy)
|
34 |
+
- **Easy to use**: Minimalist yet intuitive interface for all users
|
35 |
+
- **Cross-platform**: Models run inside the browser, both on desktop and mobile
|
36 |
+
- **Integrated**: Search from the browser address bar by setting it as the default search engine
|
37 |
+
- **Efficient**: Models are loaded and cached only when needed
|
38 |
+
- **Customizable**: Tweakable settings for search results and text generation
|
39 |
+
- **Open-source**: [The code is available for inspection and contribution at GitHub](https://github.com/QubitPi/MiniSearch)
|
40 |
+
|
41 |
+
## Prerequisites
|
42 |
+
|
43 |
+
- [Docker](https://docs.docker.com/get-docker/)
|
44 |
+
|
45 |
+
## Getting started
|
46 |
+
|
47 |
+
There are two ways to get started with MiniSearch. Pick one that suits you best.
|
48 |
+
|
49 |
+
**Option 1** - Use [MiniSearch's Docker Image][Docker Hub URL] by running:
|
50 |
+
|
51 |
+
```bash
|
52 |
+
docker run -p 7860:7860 jack20191124/mini-search:main
|
53 |
+
```
|
54 |
+
|
55 |
+
**Option 2** - Build from source by [downloading the repository files](https://github.com/QubitPi/MiniSearch/archive/refs/heads/main.zip) and running:
|
56 |
+
|
57 |
+
```bash
|
58 |
+
docker compose -f docker-compose.production.yml up --build
|
59 |
+
```
|
60 |
+
|
61 |
+
Then, open http://localhost:7860 in your browser and start searching!
|
62 |
+
|
63 |
+
## Frequently asked questions
|
64 |
+
|
65 |
+
<details>
|
66 |
+
<summary>How do I search via the browser's address bar?</summary>
|
67 |
+
<p>
|
68 |
+
You can set MiniSearch as your browser's address-bar search engine using the pattern <code>http://localhost:7860/?q=%s</code>, in which your search term replaces <code>%s</code>.
|
69 |
+
</p>
|
70 |
+
</details>
|
71 |
+
|
72 |
+
<details>
|
73 |
+
<summary>Can I use custom models via OpenAI-Compatible API?</summary>
|
74 |
+
<p>
|
75 |
+
Yes! For this, open the Menu and change the "AI Processing Location" to <code>Remote server (API)</code>. Then configure the Base URL, and optionally set an API Key and a Model to use.
|
76 |
+
</p>
|
77 |
+
</details>
|
78 |
+
|
79 |
+
<details>
|
80 |
+
<summary>How do I restrict the access to my MiniSearch instance via password?</summary>
|
81 |
+
<p>
|
82 |
+
Create a <code>.env</code> file and set a value for <code>ACCESS_KEYS</code>. Then reset the MiniSearch docker container.
|
83 |
+
</p>
|
84 |
+
<p>
|
85 |
+
For example, if you to set the password to <code>PepperoniPizza</code>, then this is what you should add to your <code>.env</code>:<br/>
|
86 |
+
<code>ACCESS_KEYS="PepperoniPizza"</code>
|
87 |
+
</p>
|
88 |
+
<p>
|
89 |
+
You can find more examples in the <code>.env.example</code> file.
|
90 |
+
</p>
|
91 |
+
</details>
|
92 |
+
|
93 |
+
<details>
|
94 |
+
<summary>I want to serve MiniSearch to other users, allowing them to use my own OpenAI-Compatible API key, but without revealing it to them. Is it possible?</summary>
|
95 |
+
<p>Yes! In MiniSearch, we call this text-generation feature "Internal OpenAI-Compatible API". To use this it:</p>
|
96 |
+
<ol>
|
97 |
+
<li>Set up your OpenAI-Compatible API endpoint by configuring the following environment variables in your <code>.env</code> file:
|
98 |
+
<ul>
|
99 |
+
<li><code>INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL</code>: The base URL for your API</li>
|
100 |
+
<li><code>INTERNAL_OPENAI_COMPATIBLE_API_KEY</code>: Your API access key</li>
|
101 |
+
<li><code>INTERNAL_OPENAI_COMPATIBLE_API_MODEL</code>: The model to use</li>
|
102 |
+
<li><code>INTERNAL_OPENAI_COMPATIBLE_API_NAME</code>: The name to display in the UI</li>
|
103 |
+
</ul>
|
104 |
+
</li>
|
105 |
+
<li>Restart MiniSearch server.</li>
|
106 |
+
<li>In the MiniSearch menu, select the new option (named as per your <code>INTERNAL_OPENAI_COMPATIBLE_API_NAME</code> setting) from the "AI Processing Location" dropdown.</li>
|
107 |
+
</ol>
|
108 |
+
</details>
|
109 |
+
|
110 |
+
<details>
|
111 |
+
<summary>How can I contribute to the development of this tool?</summary>
|
112 |
+
<p>Fork this repository and clone it. Then, start the development server by running the following command:</p>
|
113 |
+
<p><code>docker compose up</code></p>
|
114 |
+
<p>Make your changes, push them to your fork, and open a pull request! All contributions are welcome!</p>
|
115 |
+
</details>
|
116 |
+
|
117 |
+
<details>
|
118 |
+
<summary>Why is MiniSearch built upon SearXNG's Docker Image and using a single image instead of composing it from multiple services?</summary>
|
119 |
+
<p>There are a few reasons for this:</p>
|
120 |
+
<ul>
|
121 |
+
<li>MiniSearch utilizes SearXNG as its meta-search engine.</li>
|
122 |
+
<li>Manual installation of SearXNG is not trivial, so we use the docker image they provide, which has everything set up.</li>
|
123 |
+
<li>SearXNG only provides a Docker Image based on Alpine Linux.</li>
|
124 |
+
<li>The user of the image needs to be customized in a specific way to run on HuggingFace Spaces, where MiniSearch's demo runs.</li>
|
125 |
+
<li>HuggingFace only accepts a single docker image. It doesn't run docker compose or multiple images, unfortunately.</li>
|
126 |
+
</ul>
|
127 |
+
</details>
|
128 |
+
|
129 |
+
License
|
130 |
+
-------
|
131 |
+
|
132 |
+
The use and distribution terms for [MiniSearch]() are covered by the [Apache License, Version 2.0].
|
133 |
+
|
134 |
+
[Apache License Badge]: https://img.shields.io/badge/Apache%202.0-F25910.svg?style=for-the-badge&logo=Apache&logoColor=white
|
135 |
+
[Apache License, Version 2.0]: https://www.apache.org/licenses/LICENSE-2.0
|
136 |
+
|
137 |
+
[Docker Pulls Badge]: https://img.shields.io/docker/pulls/jack20191124/mini-search?style=for-the-badge&logo=docker&color=2596EC
|
138 |
+
[Docker Hub URL]: https://hub.docker.com/r/jack20191124/mini-search
|
139 |
+
|
140 |
+
[GitHub workflow status badge]: https://img.shields.io/github/actions/workflow/status/QubitPi/MiniSearch/on-push-to-main.yml?branch=master&style=for-the-badge&logo=github&logoColor=white&label=CI/CD
|
141 |
+
[GitHub workflow status URL]: https://github.com/QubitPi/MiniSearch/actions/workflows/on-push-to-main.yml
|
142 |
+
|
143 |
+
[Hugging Face space badge]: https://img.shields.io/badge/Hugging%20Face%20Space-MiniSearch-FFD21E?style=for-the-badge&logo=huggingface&logoColor=white
|
144 |
+
[Hugging Face space URL]: https://huggingface.co/spaces/QubitPi/miniSearch
|
145 |
+
[Hugging Face sync status badge]: https://img.shields.io/github/actions/workflow/status/QubitPi/MiniSearch/on-push-to-main.yml?branch=master&style=for-the-badge&logo=github&logoColor=white&label=Hugging%20Face%20Sync%20Up
|
146 |
+
[Hugging Face sync status URL]: https://github.com/QubitPi/MiniSearch/actions/workflows/on-push-to-main.yml
|
biome.json
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
3 |
+
"vcs": {
|
4 |
+
"enabled": false,
|
5 |
+
"clientKind": "git",
|
6 |
+
"useIgnoreFile": false
|
7 |
+
},
|
8 |
+
"files": {
|
9 |
+
"ignoreUnknown": false,
|
10 |
+
"ignore": []
|
11 |
+
},
|
12 |
+
"formatter": {
|
13 |
+
"enabled": true,
|
14 |
+
"indentStyle": "space"
|
15 |
+
},
|
16 |
+
"organizeImports": {
|
17 |
+
"enabled": true
|
18 |
+
},
|
19 |
+
"linter": {
|
20 |
+
"enabled": true,
|
21 |
+
"rules": {
|
22 |
+
"recommended": true
|
23 |
+
}
|
24 |
+
},
|
25 |
+
"javascript": {
|
26 |
+
"formatter": {
|
27 |
+
"quoteStyle": "double"
|
28 |
+
}
|
29 |
+
}
|
30 |
+
}
|
client/components/AiResponse/AiModelDownloadAllowanceContent.tsx
ADDED
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Alert, Button, Group, Text } from "@mantine/core";
|
2 |
+
import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
|
3 |
+
import { usePubSub } from "create-pubsub/react";
|
4 |
+
import { useState } from "react";
|
5 |
+
import { addLogEntry } from "../../modules/logEntries";
|
6 |
+
import { settingsPubSub } from "../../modules/pubSub";
|
7 |
+
|
8 |
+
export default function AiModelDownloadAllowanceContent() {
|
9 |
+
const [settings, setSettings] = usePubSub(settingsPubSub);
|
10 |
+
const [hasDeniedDownload, setDeniedDownload] = useState(false);
|
11 |
+
|
12 |
+
const handleAccept = () => {
|
13 |
+
setSettings({
|
14 |
+
...settings,
|
15 |
+
allowAiModelDownload: true,
|
16 |
+
});
|
17 |
+
addLogEntry("User allowed the AI model download");
|
18 |
+
};
|
19 |
+
|
20 |
+
const handleDecline = () => {
|
21 |
+
setDeniedDownload(true);
|
22 |
+
addLogEntry("User denied the AI model download");
|
23 |
+
};
|
24 |
+
|
25 |
+
return hasDeniedDownload ? null : (
|
26 |
+
<Alert
|
27 |
+
variant="light"
|
28 |
+
color="blue"
|
29 |
+
title="Allow AI model download?"
|
30 |
+
icon={<IconInfoCircle />}
|
31 |
+
>
|
32 |
+
<Text size="sm" mb="md">
|
33 |
+
To obtain AI responses, a language model needs to be downloaded to your
|
34 |
+
browser. Enabling this option lets the app store it and load it
|
35 |
+
instantly on subsequent uses.
|
36 |
+
</Text>
|
37 |
+
<Text size="sm" mb="md">
|
38 |
+
Please note that the download size ranges from 100 MB to 4 GB, depending
|
39 |
+
on the model you select in the Menu, so it's best to avoid using mobile
|
40 |
+
data for this.
|
41 |
+
</Text>
|
42 |
+
<Group justify="flex-end" mt="md">
|
43 |
+
<Button
|
44 |
+
variant="subtle"
|
45 |
+
color="gray"
|
46 |
+
leftSection={<IconX size="1rem" />}
|
47 |
+
onClick={handleDecline}
|
48 |
+
size="xs"
|
49 |
+
>
|
50 |
+
Not now
|
51 |
+
</Button>
|
52 |
+
<Button
|
53 |
+
leftSection={<IconCheck size="1rem" />}
|
54 |
+
onClick={handleAccept}
|
55 |
+
size="xs"
|
56 |
+
>
|
57 |
+
Allow download
|
58 |
+
</Button>
|
59 |
+
</Group>
|
60 |
+
</Alert>
|
61 |
+
);
|
62 |
+
}
|
client/components/AiResponse/AiResponseContent.tsx
ADDED
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
ActionIcon,
|
3 |
+
Alert,
|
4 |
+
Badge,
|
5 |
+
Box,
|
6 |
+
Card,
|
7 |
+
CopyButton,
|
8 |
+
Group,
|
9 |
+
ScrollArea,
|
10 |
+
Text,
|
11 |
+
Tooltip,
|
12 |
+
} from "@mantine/core";
|
13 |
+
import {
|
14 |
+
IconArrowsMaximize,
|
15 |
+
IconArrowsMinimize,
|
16 |
+
IconCheck,
|
17 |
+
IconCopy,
|
18 |
+
IconHandStop,
|
19 |
+
IconInfoCircle,
|
20 |
+
IconRefresh,
|
21 |
+
} from "@tabler/icons-react";
|
22 |
+
import type { PublishFunction } from "create-pubsub";
|
23 |
+
import { usePubSub } from "create-pubsub/react";
|
24 |
+
import { type ReactNode, Suspense, lazy, useMemo } from "react";
|
25 |
+
import { match } from "ts-pattern";
|
26 |
+
import { settingsPubSub } from "../../modules/pubSub";
|
27 |
+
import { searchAndRespond } from "../../modules/textGeneration";
|
28 |
+
|
29 |
+
const FormattedMarkdown = lazy(() => import("./FormattedMarkdown"));
|
30 |
+
|
31 |
+
export default function AiResponseContent({
|
32 |
+
textGenerationState,
|
33 |
+
response,
|
34 |
+
setTextGenerationState,
|
35 |
+
}: {
|
36 |
+
textGenerationState: string;
|
37 |
+
response: string;
|
38 |
+
setTextGenerationState: PublishFunction<
|
39 |
+
| "failed"
|
40 |
+
| "awaitingSearchResults"
|
41 |
+
| "preparingToGenerate"
|
42 |
+
| "idle"
|
43 |
+
| "loadingModel"
|
44 |
+
| "generating"
|
45 |
+
| "interrupted"
|
46 |
+
| "completed"
|
47 |
+
>;
|
48 |
+
}) {
|
49 |
+
const [settings, setSettings] = usePubSub(settingsPubSub);
|
50 |
+
|
51 |
+
const ConditionalScrollArea = useMemo(
|
52 |
+
() =>
|
53 |
+
({ children }: { children: ReactNode }) => {
|
54 |
+
return settings.enableAiResponseScrolling ? (
|
55 |
+
<ScrollArea.Autosize mah={300} type="auto" offsetScrollbars>
|
56 |
+
{children}
|
57 |
+
</ScrollArea.Autosize>
|
58 |
+
) : (
|
59 |
+
<Box>{children}</Box>
|
60 |
+
);
|
61 |
+
},
|
62 |
+
[settings.enableAiResponseScrolling],
|
63 |
+
);
|
64 |
+
|
65 |
+
return (
|
66 |
+
<Card withBorder shadow="sm" radius="md">
|
67 |
+
<Card.Section withBorder inheritPadding py="xs">
|
68 |
+
<Group justify="space-between">
|
69 |
+
<Group gap="xs" align="center">
|
70 |
+
<Text fw={500}>
|
71 |
+
{match(textGenerationState)
|
72 |
+
.with("generating", () => "Generating AI Response...")
|
73 |
+
.otherwise(() => "AI Response")}
|
74 |
+
</Text>
|
75 |
+
{match(textGenerationState)
|
76 |
+
.with("interrupted", () => (
|
77 |
+
<Badge variant="light" color="yellow" size="xs">
|
78 |
+
Interrupted
|
79 |
+
</Badge>
|
80 |
+
))
|
81 |
+
.otherwise(() => null)}
|
82 |
+
</Group>
|
83 |
+
<Group gap="xs" align="center">
|
84 |
+
{match(textGenerationState)
|
85 |
+
.with("generating", () => (
|
86 |
+
<Tooltip label="Interrupt generation">
|
87 |
+
<ActionIcon
|
88 |
+
onClick={() => setTextGenerationState("interrupted")}
|
89 |
+
variant="subtle"
|
90 |
+
color="gray"
|
91 |
+
>
|
92 |
+
<IconHandStop size={16} />
|
93 |
+
</ActionIcon>
|
94 |
+
</Tooltip>
|
95 |
+
))
|
96 |
+
.otherwise(() => (
|
97 |
+
<Tooltip label="Regenerate response">
|
98 |
+
<ActionIcon
|
99 |
+
onClick={() => searchAndRespond()}
|
100 |
+
variant="subtle"
|
101 |
+
color="gray"
|
102 |
+
>
|
103 |
+
<IconRefresh size={16} />
|
104 |
+
</ActionIcon>
|
105 |
+
</Tooltip>
|
106 |
+
))}
|
107 |
+
{settings.enableAiResponseScrolling ? (
|
108 |
+
<Tooltip label="Show full response without scroll bar">
|
109 |
+
<ActionIcon
|
110 |
+
onClick={() => {
|
111 |
+
setSettings({
|
112 |
+
...settings,
|
113 |
+
enableAiResponseScrolling: false,
|
114 |
+
});
|
115 |
+
}}
|
116 |
+
variant="subtle"
|
117 |
+
color="gray"
|
118 |
+
>
|
119 |
+
<IconArrowsMaximize size={16} />
|
120 |
+
</ActionIcon>
|
121 |
+
</Tooltip>
|
122 |
+
) : (
|
123 |
+
<Tooltip label="Enable scroll bar">
|
124 |
+
<ActionIcon
|
125 |
+
onClick={() => {
|
126 |
+
setSettings({
|
127 |
+
...settings,
|
128 |
+
enableAiResponseScrolling: true,
|
129 |
+
});
|
130 |
+
}}
|
131 |
+
variant="subtle"
|
132 |
+
color="gray"
|
133 |
+
>
|
134 |
+
<IconArrowsMinimize size={16} />
|
135 |
+
</ActionIcon>
|
136 |
+
</Tooltip>
|
137 |
+
)}
|
138 |
+
<CopyButton value={response} timeout={2000}>
|
139 |
+
{({ copied, copy }) => (
|
140 |
+
<Tooltip
|
141 |
+
label={copied ? "Copied" : "Copy response"}
|
142 |
+
withArrow
|
143 |
+
position="right"
|
144 |
+
>
|
145 |
+
<ActionIcon
|
146 |
+
color={copied ? "teal" : "gray"}
|
147 |
+
variant="subtle"
|
148 |
+
onClick={copy}
|
149 |
+
>
|
150 |
+
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
151 |
+
</ActionIcon>
|
152 |
+
</Tooltip>
|
153 |
+
)}
|
154 |
+
</CopyButton>
|
155 |
+
</Group>
|
156 |
+
</Group>
|
157 |
+
</Card.Section>
|
158 |
+
<Card.Section withBorder>
|
159 |
+
<ConditionalScrollArea>
|
160 |
+
<Suspense>
|
161 |
+
<FormattedMarkdown>{response}</FormattedMarkdown>
|
162 |
+
</Suspense>
|
163 |
+
</ConditionalScrollArea>
|
164 |
+
{match(textGenerationState)
|
165 |
+
.with("failed", () => (
|
166 |
+
<Alert
|
167 |
+
variant="light"
|
168 |
+
color="yellow"
|
169 |
+
title="Failed to generate response"
|
170 |
+
icon={<IconInfoCircle />}
|
171 |
+
>
|
172 |
+
Could not generate response. It's possible that your browser or
|
173 |
+
your system is out of memory.
|
174 |
+
</Alert>
|
175 |
+
))
|
176 |
+
.otherwise(() => null)}
|
177 |
+
</Card.Section>
|
178 |
+
</Card>
|
179 |
+
);
|
180 |
+
}
|
client/components/AiResponse/AiResponseSection.tsx
ADDED
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { usePubSub } from "create-pubsub/react";
|
2 |
+
import { Suspense, lazy, useMemo } from "react";
|
3 |
+
import { Pattern, match } from "ts-pattern";
|
4 |
+
import {
|
5 |
+
modelLoadingProgressPubSub,
|
6 |
+
modelSizeInMegabytesPubSub,
|
7 |
+
queryPubSub,
|
8 |
+
responsePubSub,
|
9 |
+
settingsPubSub,
|
10 |
+
textGenerationStatePubSub,
|
11 |
+
} from "../../modules/pubSub";
|
12 |
+
|
13 |
+
const AiResponseContent = lazy(() => import("./AiResponseContent"));
|
14 |
+
const PreparingContent = lazy(() => import("./PreparingContent"));
|
15 |
+
const LoadingModelContent = lazy(() => import("./LoadingModelContent"));
|
16 |
+
const ChatInterface = lazy(() => import("./ChatInterface"));
|
17 |
+
const AiModelDownloadAllowanceContent = lazy(
|
18 |
+
() => import("./AiModelDownloadAllowanceContent"),
|
19 |
+
);
|
20 |
+
|
21 |
+
export default function AiResponseSection() {
|
22 |
+
const [query] = usePubSub(queryPubSub);
|
23 |
+
const [response] = usePubSub(responsePubSub);
|
24 |
+
const [textGenerationState, setTextGenerationState] = usePubSub(
|
25 |
+
textGenerationStatePubSub,
|
26 |
+
);
|
27 |
+
const [modelLoadingProgress] = usePubSub(modelLoadingProgressPubSub);
|
28 |
+
const [settings] = usePubSub(settingsPubSub);
|
29 |
+
const [modelSizeInMegabytes] = usePubSub(modelSizeInMegabytesPubSub);
|
30 |
+
|
31 |
+
return useMemo(
|
32 |
+
() =>
|
33 |
+
match([settings.enableAiResponse, textGenerationState])
|
34 |
+
.with([true, Pattern.not("idle").select()], (textGenerationState) =>
|
35 |
+
match(textGenerationState)
|
36 |
+
.with(
|
37 |
+
Pattern.union("generating", "interrupted", "completed", "failed"),
|
38 |
+
(textGenerationState) => (
|
39 |
+
<>
|
40 |
+
<Suspense>
|
41 |
+
<AiResponseContent
|
42 |
+
textGenerationState={textGenerationState}
|
43 |
+
response={response}
|
44 |
+
setTextGenerationState={setTextGenerationState}
|
45 |
+
/>
|
46 |
+
</Suspense>
|
47 |
+
{textGenerationState === "completed" && (
|
48 |
+
<Suspense>
|
49 |
+
<ChatInterface
|
50 |
+
initialQuery={query}
|
51 |
+
initialResponse={response}
|
52 |
+
/>
|
53 |
+
</Suspense>
|
54 |
+
)}
|
55 |
+
</>
|
56 |
+
),
|
57 |
+
)
|
58 |
+
.with("awaitingModelDownloadAllowance", () => (
|
59 |
+
<Suspense>
|
60 |
+
<AiModelDownloadAllowanceContent />
|
61 |
+
</Suspense>
|
62 |
+
))
|
63 |
+
.with("loadingModel", () => (
|
64 |
+
<Suspense>
|
65 |
+
<LoadingModelContent
|
66 |
+
modelLoadingProgress={modelLoadingProgress}
|
67 |
+
modelSizeInMegabytes={modelSizeInMegabytes}
|
68 |
+
/>
|
69 |
+
</Suspense>
|
70 |
+
))
|
71 |
+
.with(
|
72 |
+
Pattern.union("awaitingSearchResults", "preparingToGenerate"),
|
73 |
+
(textGenerationState) => (
|
74 |
+
<Suspense>
|
75 |
+
<PreparingContent textGenerationState={textGenerationState} />
|
76 |
+
</Suspense>
|
77 |
+
),
|
78 |
+
)
|
79 |
+
.exhaustive(),
|
80 |
+
)
|
81 |
+
.otherwise(() => null),
|
82 |
+
[
|
83 |
+
settings,
|
84 |
+
textGenerationState,
|
85 |
+
setTextGenerationState,
|
86 |
+
modelLoadingProgress,
|
87 |
+
response,
|
88 |
+
query,
|
89 |
+
modelSizeInMegabytes,
|
90 |
+
],
|
91 |
+
);
|
92 |
+
}
|
client/components/AiResponse/ChatInterface.tsx
ADDED
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
ActionIcon,
|
3 |
+
Button,
|
4 |
+
Card,
|
5 |
+
CopyButton,
|
6 |
+
Group,
|
7 |
+
Paper,
|
8 |
+
Stack,
|
9 |
+
Text,
|
10 |
+
Textarea,
|
11 |
+
Tooltip,
|
12 |
+
} from "@mantine/core";
|
13 |
+
import { IconCheck, IconCopy, IconSend } from "@tabler/icons-react";
|
14 |
+
import { usePubSub } from "create-pubsub/react";
|
15 |
+
import type { ChatMessage } from "gpt-tokenizer/GptEncoding";
|
16 |
+
import {
|
17 |
+
type KeyboardEvent,
|
18 |
+
Suspense,
|
19 |
+
lazy,
|
20 |
+
useEffect,
|
21 |
+
useRef,
|
22 |
+
useState,
|
23 |
+
} from "react";
|
24 |
+
import { match } from "ts-pattern";
|
25 |
+
import { addLogEntry } from "../../modules/logEntries";
|
26 |
+
import { settingsPubSub } from "../../modules/pubSub";
|
27 |
+
import { generateChatResponse } from "../../modules/textGeneration";
|
28 |
+
|
29 |
+
const FormattedMarkdown = lazy(() => import("./FormattedMarkdown"));
|
30 |
+
|
31 |
+
export default function ChatInterface({
|
32 |
+
initialQuery,
|
33 |
+
initialResponse,
|
34 |
+
}: {
|
35 |
+
initialQuery: string;
|
36 |
+
initialResponse: string;
|
37 |
+
}) {
|
38 |
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
39 |
+
const [input, setInput] = useState("");
|
40 |
+
const [isGenerating, setIsGenerating] = useState(false);
|
41 |
+
const [streamedResponse, setStreamedResponse] = useState("");
|
42 |
+
const latestResponseRef = useRef("");
|
43 |
+
const [settings] = usePubSub(settingsPubSub);
|
44 |
+
|
45 |
+
useEffect(() => {
|
46 |
+
setMessages([
|
47 |
+
{ role: "user", content: initialQuery },
|
48 |
+
{ role: "assistant", content: initialResponse },
|
49 |
+
]);
|
50 |
+
}, [initialQuery, initialResponse]);
|
51 |
+
|
52 |
+
const handleSend = async () => {
|
53 |
+
if (input.trim() === "" || isGenerating) return;
|
54 |
+
|
55 |
+
const newMessages: ChatMessage[] = [
|
56 |
+
...messages,
|
57 |
+
{ role: "user", content: input },
|
58 |
+
];
|
59 |
+
setMessages(newMessages);
|
60 |
+
setInput("");
|
61 |
+
setIsGenerating(true);
|
62 |
+
setStreamedResponse("");
|
63 |
+
latestResponseRef.current = "";
|
64 |
+
|
65 |
+
try {
|
66 |
+
addLogEntry("User sent a follow-up question");
|
67 |
+
await generateChatResponse(newMessages, (partialResponse) => {
|
68 |
+
setStreamedResponse(partialResponse);
|
69 |
+
latestResponseRef.current = partialResponse;
|
70 |
+
});
|
71 |
+
setMessages((prevMessages) => [
|
72 |
+
...prevMessages,
|
73 |
+
{ role: "assistant", content: latestResponseRef.current },
|
74 |
+
]);
|
75 |
+
addLogEntry("AI responded to follow-up question");
|
76 |
+
} catch (error) {
|
77 |
+
addLogEntry(`Error generating chat response: ${error}`);
|
78 |
+
setMessages((prevMessages) => [
|
79 |
+
...prevMessages,
|
80 |
+
{
|
81 |
+
role: "assistant",
|
82 |
+
content: "Sorry, I encountered an error while generating a response.",
|
83 |
+
},
|
84 |
+
]);
|
85 |
+
} finally {
|
86 |
+
setIsGenerating(false);
|
87 |
+
setStreamedResponse("");
|
88 |
+
}
|
89 |
+
};
|
90 |
+
|
91 |
+
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
92 |
+
match([event, settings.enterToSubmit])
|
93 |
+
.with([{ code: "Enter", shiftKey: false }, true], () => {
|
94 |
+
event.preventDefault();
|
95 |
+
handleSend();
|
96 |
+
})
|
97 |
+
.with([{ code: "Enter", shiftKey: true }, false], () => {
|
98 |
+
event.preventDefault();
|
99 |
+
handleSend();
|
100 |
+
})
|
101 |
+
.otherwise(() => undefined);
|
102 |
+
};
|
103 |
+
|
104 |
+
const getChatContent = () => {
|
105 |
+
return messages
|
106 |
+
.slice(2)
|
107 |
+
.map(
|
108 |
+
(msg, index) =>
|
109 |
+
`${index + 1}. ${msg.role?.toUpperCase()}\n\n${msg.content}`,
|
110 |
+
)
|
111 |
+
.join("\n\n");
|
112 |
+
};
|
113 |
+
|
114 |
+
return (
|
115 |
+
<Card withBorder shadow="sm" radius="md">
|
116 |
+
<Card.Section withBorder inheritPadding py="xs">
|
117 |
+
<Group justify="space-between">
|
118 |
+
<Text fw={500}>Follow-up questions</Text>
|
119 |
+
{messages.length > 2 && (
|
120 |
+
<CopyButton value={getChatContent()} timeout={2000}>
|
121 |
+
{({ copied, copy }) => (
|
122 |
+
<Tooltip
|
123 |
+
label={copied ? "Copied" : "Copy conversation"}
|
124 |
+
withArrow
|
125 |
+
position="right"
|
126 |
+
>
|
127 |
+
<ActionIcon
|
128 |
+
color={copied ? "teal" : "gray"}
|
129 |
+
variant="subtle"
|
130 |
+
onClick={copy}
|
131 |
+
>
|
132 |
+
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
133 |
+
</ActionIcon>
|
134 |
+
</Tooltip>
|
135 |
+
)}
|
136 |
+
</CopyButton>
|
137 |
+
)}
|
138 |
+
</Group>
|
139 |
+
</Card.Section>
|
140 |
+
<Stack gap="md" pt="md">
|
141 |
+
{messages.slice(2).length > 0 && (
|
142 |
+
<Stack gap="md">
|
143 |
+
{messages.slice(2).map((message, index) => (
|
144 |
+
<Paper
|
145 |
+
key={`${message.role}-${index}`}
|
146 |
+
shadow="xs"
|
147 |
+
radius="xl"
|
148 |
+
p="sm"
|
149 |
+
maw="90%"
|
150 |
+
style={{
|
151 |
+
alignSelf:
|
152 |
+
message.role === "user" ? "flex-end" : "flex-start",
|
153 |
+
}}
|
154 |
+
>
|
155 |
+
<Suspense>
|
156 |
+
<FormattedMarkdown>{message.content}</FormattedMarkdown>
|
157 |
+
</Suspense>
|
158 |
+
</Paper>
|
159 |
+
))}
|
160 |
+
{isGenerating && streamedResponse.length > 0 && (
|
161 |
+
<Paper
|
162 |
+
shadow="xs"
|
163 |
+
radius="xl"
|
164 |
+
p="sm"
|
165 |
+
maw="90%"
|
166 |
+
style={{ alignSelf: "flex-start" }}
|
167 |
+
>
|
168 |
+
<Suspense>
|
169 |
+
<FormattedMarkdown>{streamedResponse}</FormattedMarkdown>
|
170 |
+
</Suspense>
|
171 |
+
</Paper>
|
172 |
+
)}
|
173 |
+
</Stack>
|
174 |
+
)}
|
175 |
+
<Group align="flex-end" style={{ position: "relative" }}>
|
176 |
+
<Textarea
|
177 |
+
placeholder="Anything else you would like to know?"
|
178 |
+
value={input}
|
179 |
+
onChange={(event) => setInput(event.currentTarget.value)}
|
180 |
+
onKeyDown={handleKeyDown}
|
181 |
+
autosize
|
182 |
+
minRows={1}
|
183 |
+
maxRows={4}
|
184 |
+
style={{ flexGrow: 1, paddingRight: "50px" }}
|
185 |
+
disabled={isGenerating}
|
186 |
+
/>
|
187 |
+
<Button
|
188 |
+
size="sm"
|
189 |
+
variant="default"
|
190 |
+
onClick={handleSend}
|
191 |
+
loading={isGenerating}
|
192 |
+
style={{
|
193 |
+
height: "100%",
|
194 |
+
position: "absolute",
|
195 |
+
right: 0,
|
196 |
+
top: 0,
|
197 |
+
bottom: 0,
|
198 |
+
borderTopLeftRadius: 0,
|
199 |
+
borderBottomLeftRadius: 0,
|
200 |
+
}}
|
201 |
+
>
|
202 |
+
<IconSend size={16} />
|
203 |
+
</Button>
|
204 |
+
</Group>
|
205 |
+
</Stack>
|
206 |
+
</Card>
|
207 |
+
);
|
208 |
+
}
|
client/components/AiResponse/FormattedMarkdown.tsx
ADDED
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { TypographyStylesProvider } from "@mantine/core";
|
2 |
+
import Markdown from "react-markdown";
|
3 |
+
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
4 |
+
import syntaxHighlighterStyle from "react-syntax-highlighter/dist/esm/styles/prism/one-dark";
|
5 |
+
|
6 |
+
const FormattedMarkdown = ({ children }: { children: string }) => {
|
7 |
+
return (
|
8 |
+
<TypographyStylesProvider p="md">
|
9 |
+
<Markdown
|
10 |
+
components={{
|
11 |
+
code(props) {
|
12 |
+
const { children, className, node, ref, ...rest } = props;
|
13 |
+
void node;
|
14 |
+
const languageMatch = /language-(\w+)/.exec(className || "");
|
15 |
+
return languageMatch ? (
|
16 |
+
<SyntaxHighlighter
|
17 |
+
{...rest}
|
18 |
+
ref={ref as never}
|
19 |
+
language={languageMatch[1]}
|
20 |
+
style={syntaxHighlighterStyle}
|
21 |
+
>
|
22 |
+
{children?.toString().replace(/\n$/, "") ?? ""}
|
23 |
+
</SyntaxHighlighter>
|
24 |
+
) : (
|
25 |
+
<code {...rest} className={className}>
|
26 |
+
{children}
|
27 |
+
</code>
|
28 |
+
);
|
29 |
+
},
|
30 |
+
}}
|
31 |
+
>
|
32 |
+
{children}
|
33 |
+
</Markdown>
|
34 |
+
</TypographyStylesProvider>
|
35 |
+
);
|
36 |
+
};
|
37 |
+
|
38 |
+
export default FormattedMarkdown;
|
client/components/AiResponse/LoadingModelContent.tsx
ADDED
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Card, Group, Progress, Stack, Text } from "@mantine/core";
|
2 |
+
|
3 |
+
export default function LoadingModelContent({
|
4 |
+
modelLoadingProgress,
|
5 |
+
modelSizeInMegabytes,
|
6 |
+
}: {
|
7 |
+
modelLoadingProgress: number;
|
8 |
+
modelSizeInMegabytes: number;
|
9 |
+
}) {
|
10 |
+
const isLoadingStarting = modelLoadingProgress === 0;
|
11 |
+
const isLoadingComplete = modelLoadingProgress === 100;
|
12 |
+
const percent =
|
13 |
+
isLoadingComplete || isLoadingStarting ? 100 : modelLoadingProgress;
|
14 |
+
const strokeColor = percent === 100 ? "#52c41a" : "#3385ff";
|
15 |
+
const downloadedSize = (modelSizeInMegabytes * modelLoadingProgress) / 100;
|
16 |
+
const sizeText = `${downloadedSize.toFixed(0)} MB / ${modelSizeInMegabytes.toFixed(0)} MB`;
|
17 |
+
|
18 |
+
return (
|
19 |
+
<Card withBorder shadow="sm" radius="md">
|
20 |
+
<Card.Section withBorder inheritPadding py="xs">
|
21 |
+
<Text fw={500}>Loading AI...</Text>
|
22 |
+
</Card.Section>
|
23 |
+
<Card.Section withBorder inheritPadding py="md">
|
24 |
+
<Stack gap="xs">
|
25 |
+
<Progress color={strokeColor} value={percent} animated />
|
26 |
+
{!isLoadingStarting && (
|
27 |
+
<Group justify="space-between">
|
28 |
+
<Text size="sm" c="dimmed">
|
29 |
+
{sizeText}
|
30 |
+
</Text>
|
31 |
+
<Text size="sm" c="dimmed">
|
32 |
+
{percent.toFixed(1)}%
|
33 |
+
</Text>
|
34 |
+
</Group>
|
35 |
+
)}
|
36 |
+
</Stack>
|
37 |
+
</Card.Section>
|
38 |
+
</Card>
|
39 |
+
);
|
40 |
+
}
|
client/components/AiResponse/PreparingContent.tsx
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Card, Skeleton, Stack, Text } from "@mantine/core";
|
2 |
+
import { match } from "ts-pattern";
|
3 |
+
|
4 |
+
export default function PreparingContent({
|
5 |
+
textGenerationState,
|
6 |
+
}: {
|
7 |
+
textGenerationState: string;
|
8 |
+
}) {
|
9 |
+
return (
|
10 |
+
<Card withBorder shadow="sm" radius="md">
|
11 |
+
<Card.Section withBorder inheritPadding py="xs">
|
12 |
+
<Text fw={500}>
|
13 |
+
{match(textGenerationState)
|
14 |
+
.with("awaitingSearchResults", () => "Awaiting search results...")
|
15 |
+
.with("preparingToGenerate", () => "Preparing AI response...")
|
16 |
+
.otherwise(() => null)}
|
17 |
+
</Text>
|
18 |
+
</Card.Section>
|
19 |
+
<Card.Section withBorder inheritPadding py="md">
|
20 |
+
<Stack>
|
21 |
+
<Skeleton height={8} radius="xl" />
|
22 |
+
<Skeleton height={8} width="70%" radius="xl" />
|
23 |
+
<Skeleton height={8} radius="xl" />
|
24 |
+
<Skeleton height={8} width="43%" radius="xl" />
|
25 |
+
</Stack>
|
26 |
+
</Card.Section>
|
27 |
+
</Card>
|
28 |
+
);
|
29 |
+
}
|
client/components/AiResponse/WebLlmModelSelect.tsx
ADDED
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { type ComboboxItem, Select } from "@mantine/core";
|
2 |
+
import { prebuiltAppConfig } from "@mlc-ai/web-llm";
|
3 |
+
import { useCallback, useEffect, useState } from "react";
|
4 |
+
import { isF16Supported } from "../../modules/webGpu";
|
5 |
+
|
6 |
+
export default function WebLlmModelSelect({
|
7 |
+
value,
|
8 |
+
onChange,
|
9 |
+
}: {
|
10 |
+
value: string;
|
11 |
+
onChange: (value: string) => void;
|
12 |
+
}) {
|
13 |
+
const [webGpuModels] = useState<ComboboxItem[]>(() => {
|
14 |
+
const models = prebuiltAppConfig.model_list
|
15 |
+
.filter((model) => {
|
16 |
+
const isSmall = isSmallModel(model);
|
17 |
+
const suffix = getModelSuffix(isF16Supported, isSmall);
|
18 |
+
return model.model_id.endsWith(suffix);
|
19 |
+
})
|
20 |
+
.sort((a, b) => (a.vram_required_MB ?? 0) - (b.vram_required_MB ?? 0))
|
21 |
+
.map((model) => {
|
22 |
+
const modelSizeInMegabytes =
|
23 |
+
Math.round(model.vram_required_MB ?? 0) || "N/A";
|
24 |
+
const isSmall = isSmallModel(model);
|
25 |
+
const suffix = getModelSuffix(isF16Supported, isSmall);
|
26 |
+
const modelName = model.model_id.replace(suffix, "");
|
27 |
+
|
28 |
+
return {
|
29 |
+
label: `${modelSizeInMegabytes} MB β’ ${modelName}`,
|
30 |
+
value: model.model_id,
|
31 |
+
};
|
32 |
+
});
|
33 |
+
|
34 |
+
return models;
|
35 |
+
});
|
36 |
+
|
37 |
+
useEffect(() => {
|
38 |
+
const isCurrentModelValid = webGpuModels.some(
|
39 |
+
(model) => model.value === value,
|
40 |
+
);
|
41 |
+
|
42 |
+
if (!isCurrentModelValid && webGpuModels.length > 0) {
|
43 |
+
onChange(webGpuModels[0].value);
|
44 |
+
}
|
45 |
+
}, [onChange, webGpuModels, value]);
|
46 |
+
|
47 |
+
const handleChange = useCallback(
|
48 |
+
(value: string | null) => {
|
49 |
+
if (value) onChange(value);
|
50 |
+
},
|
51 |
+
[onChange],
|
52 |
+
);
|
53 |
+
|
54 |
+
return (
|
55 |
+
<Select
|
56 |
+
value={value}
|
57 |
+
onChange={handleChange}
|
58 |
+
label="AI Model"
|
59 |
+
description="Select the model to use for AI responses."
|
60 |
+
data={webGpuModels}
|
61 |
+
allowDeselect={false}
|
62 |
+
searchable
|
63 |
+
/>
|
64 |
+
);
|
65 |
+
}
|
66 |
+
|
67 |
+
type ModelConfig = (typeof prebuiltAppConfig.model_list)[number];
|
68 |
+
|
69 |
+
const smallModels = ["SmolLM2-135M", "SmolLM2-360M"] as const;
|
70 |
+
|
71 |
+
function isSmallModel(model: ModelConfig) {
|
72 |
+
return smallModels.some((smallModel) =>
|
73 |
+
model.model_id.startsWith(smallModel),
|
74 |
+
);
|
75 |
+
}
|
76 |
+
|
77 |
+
function getModelSuffix(isF16: boolean, isSmall: boolean) {
|
78 |
+
if (isSmall) return isF16 ? "-q0f16-MLC" : "-q0f32-MLC";
|
79 |
+
|
80 |
+
return isF16 ? "-q4f16_1-MLC" : "-q4f32_1-MLC";
|
81 |
+
}
|
client/components/AiResponse/WllamaModelSelect.tsx
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { type ComboboxItem, Select } from "@mantine/core";
|
2 |
+
import { useEffect, useState } from "react";
|
3 |
+
import { wllamaModels } from "../../modules/wllama";
|
4 |
+
|
5 |
+
export default function WllamaModelSelect({
|
6 |
+
value,
|
7 |
+
onChange,
|
8 |
+
}: {
|
9 |
+
value: string;
|
10 |
+
onChange: (value: string) => void;
|
11 |
+
}) {
|
12 |
+
const [wllamaModelOptions] = useState<ComboboxItem[]>(
|
13 |
+
Object.entries(wllamaModels)
|
14 |
+
.sort(([, a], [, b]) => a.fileSizeInMegabytes - b.fileSizeInMegabytes)
|
15 |
+
.map(([value, { label, fileSizeInMegabytes }]) => ({
|
16 |
+
label: `${fileSizeInMegabytes} MB β’ ${label}`,
|
17 |
+
value,
|
18 |
+
})),
|
19 |
+
);
|
20 |
+
|
21 |
+
useEffect(() => {
|
22 |
+
const isCurrentModelValid = wllamaModelOptions.some(
|
23 |
+
(model) => model.value === value,
|
24 |
+
);
|
25 |
+
|
26 |
+
if (!isCurrentModelValid && wllamaModelOptions.length > 0) {
|
27 |
+
onChange(wllamaModelOptions[0].value);
|
28 |
+
}
|
29 |
+
}, [onChange, wllamaModelOptions, value]);
|
30 |
+
|
31 |
+
return (
|
32 |
+
<Select
|
33 |
+
value={value}
|
34 |
+
onChange={(value) => value && onChange(value)}
|
35 |
+
label="AI Model"
|
36 |
+
description="Select the model to use for AI responses."
|
37 |
+
data={wllamaModelOptions}
|
38 |
+
allowDeselect={false}
|
39 |
+
searchable
|
40 |
+
/>
|
41 |
+
);
|
42 |
+
}
|
client/components/App/App.tsx
ADDED
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { MantineProvider } from "@mantine/core";
|
2 |
+
import { Route, Switch } from "wouter";
|
3 |
+
import "@mantine/core/styles.css";
|
4 |
+
import { Notifications } from "@mantine/notifications";
|
5 |
+
import { usePubSub } from "create-pubsub/react";
|
6 |
+
import { lazy, useEffect, useState } from "react";
|
7 |
+
import { addLogEntry } from "../../modules/logEntries";
|
8 |
+
import { settingsPubSub } from "../../modules/pubSub";
|
9 |
+
import { defaultSettings } from "../../modules/settings";
|
10 |
+
import "@mantine/notifications/styles.css";
|
11 |
+
import { match } from "ts-pattern";
|
12 |
+
import { verifyStoredAccessKey } from "../../modules/accessKey";
|
13 |
+
|
14 |
+
const MainPage = lazy(() => import("../Pages/Main/MainPage"));
|
15 |
+
const AccessPage = lazy(() => import("../Pages/AccessPage"));
|
16 |
+
|
17 |
+
export function App() {
|
18 |
+
useInitializeSettings();
|
19 |
+
const { hasValidatedAccessKey, isCheckingStoredKey, setValidatedAccessKey } =
|
20 |
+
useAccessKeyValidation();
|
21 |
+
|
22 |
+
return match(isCheckingStoredKey)
|
23 |
+
.with(false, () => (
|
24 |
+
<MantineProvider defaultColorScheme="dark">
|
25 |
+
<Notifications />
|
26 |
+
<Switch>
|
27 |
+
<Route path="/">
|
28 |
+
{match([VITE_ACCESS_KEYS_ENABLED, hasValidatedAccessKey])
|
29 |
+
.with([true, false], () => (
|
30 |
+
<AccessPage
|
31 |
+
onAccessKeyValid={() => setValidatedAccessKey(true)}
|
32 |
+
/>
|
33 |
+
))
|
34 |
+
.otherwise(() => (
|
35 |
+
<MainPage />
|
36 |
+
))}
|
37 |
+
</Route>
|
38 |
+
</Switch>
|
39 |
+
</MantineProvider>
|
40 |
+
))
|
41 |
+
.otherwise(() => null);
|
42 |
+
}
|
43 |
+
|
44 |
+
/**
|
45 |
+
* A custom React hook that initializes the application settings.
|
46 |
+
*
|
47 |
+
* @returns The initialized settings object.
|
48 |
+
*
|
49 |
+
* @remarks
|
50 |
+
* This hook uses the `usePubSub` hook to access and update the settings state.
|
51 |
+
* It initializes the settings by merging the default settings with any existing settings.
|
52 |
+
* The initialization is performed once when the component mounts.
|
53 |
+
*/
|
54 |
+
function useInitializeSettings() {
|
55 |
+
const [settings, setSettings] = usePubSub(settingsPubSub);
|
56 |
+
const [settingsInitialized, setSettingsInitialized] = useState(false);
|
57 |
+
|
58 |
+
useEffect(() => {
|
59 |
+
if (settingsInitialized) return;
|
60 |
+
|
61 |
+
setSettings({ ...defaultSettings, ...settings });
|
62 |
+
|
63 |
+
setSettingsInitialized(true);
|
64 |
+
|
65 |
+
addLogEntry("Settings initialized");
|
66 |
+
}, [settings, setSettings, settingsInitialized]);
|
67 |
+
|
68 |
+
return settings;
|
69 |
+
}
|
70 |
+
|
71 |
+
/**
|
72 |
+
* A custom React hook that validates the stored access key on mount.
|
73 |
+
*
|
74 |
+
* @returns An object containing the validation state and loading state
|
75 |
+
*/
|
76 |
+
function useAccessKeyValidation() {
|
77 |
+
const [hasValidatedAccessKey, setValidatedAccessKey] = useState(false);
|
78 |
+
const [isCheckingStoredKey, setCheckingStoredKey] = useState(true);
|
79 |
+
|
80 |
+
useEffect(() => {
|
81 |
+
async function checkStoredAccessKey() {
|
82 |
+
if (VITE_ACCESS_KEYS_ENABLED) {
|
83 |
+
const isValid = await verifyStoredAccessKey();
|
84 |
+
if (isValid) setValidatedAccessKey(true);
|
85 |
+
}
|
86 |
+
setCheckingStoredKey(false);
|
87 |
+
}
|
88 |
+
|
89 |
+
checkStoredAccessKey();
|
90 |
+
}, []);
|
91 |
+
|
92 |
+
return {
|
93 |
+
hasValidatedAccessKey,
|
94 |
+
isCheckingStoredKey,
|
95 |
+
setValidatedAccessKey,
|
96 |
+
};
|
97 |
+
}
|
client/components/Logs/LogsModal.tsx
ADDED
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
Alert,
|
3 |
+
Button,
|
4 |
+
Center,
|
5 |
+
Group,
|
6 |
+
Modal,
|
7 |
+
Pagination,
|
8 |
+
Table,
|
9 |
+
} from "@mantine/core";
|
10 |
+
import { IconInfoCircle } from "@tabler/icons-react";
|
11 |
+
import { usePubSub } from "create-pubsub/react";
|
12 |
+
import { useCallback, useMemo, useState } from "react";
|
13 |
+
import { logEntriesPubSub } from "../../modules/logEntries";
|
14 |
+
|
15 |
+
export default function LogsModal({
|
16 |
+
opened,
|
17 |
+
onClose,
|
18 |
+
}: {
|
19 |
+
opened: boolean;
|
20 |
+
onClose: () => void;
|
21 |
+
}) {
|
22 |
+
const [logEntries] = usePubSub(logEntriesPubSub);
|
23 |
+
|
24 |
+
const [page, setPage] = useState(1);
|
25 |
+
|
26 |
+
const logEntriesPerPage = 5;
|
27 |
+
|
28 |
+
const logEntriesFromCurrentPage = useMemo(
|
29 |
+
() =>
|
30 |
+
logEntries.slice(
|
31 |
+
(page - 1) * logEntriesPerPage,
|
32 |
+
page * logEntriesPerPage,
|
33 |
+
),
|
34 |
+
[logEntries, page],
|
35 |
+
);
|
36 |
+
|
37 |
+
const downloadLogsAsJson = useCallback(() => {
|
38 |
+
const jsonString = JSON.stringify(logEntries, null, 2);
|
39 |
+
const blob = new Blob([jsonString], { type: "application/json" });
|
40 |
+
const url = URL.createObjectURL(blob);
|
41 |
+
const link = document.createElement("a");
|
42 |
+
link.href = url;
|
43 |
+
link.download = "logs.json";
|
44 |
+
document.body.appendChild(link);
|
45 |
+
link.click();
|
46 |
+
document.body.removeChild(link);
|
47 |
+
URL.revokeObjectURL(url);
|
48 |
+
}, [logEntries]);
|
49 |
+
|
50 |
+
return (
|
51 |
+
<Modal opened={opened} onClose={onClose} size="xl" title="Logs">
|
52 |
+
<Alert variant="light" color="blue" icon={<IconInfoCircle />} mb="md">
|
53 |
+
<Group justify="space-between" align="center">
|
54 |
+
<span>
|
55 |
+
This information is stored solely in your browser for personal use.
|
56 |
+
It isn't sent automatically and is retained for debugging purposes
|
57 |
+
should you need to{" "}
|
58 |
+
<a
|
59 |
+
href="https://github.com/felladrin/MiniSearch/issues/new?labels=bug&template=bug_report.yml"
|
60 |
+
target="_blank"
|
61 |
+
rel="noopener noreferrer"
|
62 |
+
>
|
63 |
+
report a bug
|
64 |
+
</a>
|
65 |
+
.
|
66 |
+
</span>
|
67 |
+
<Button onClick={downloadLogsAsJson} size="xs" data-autofocus>
|
68 |
+
Download Logs
|
69 |
+
</Button>
|
70 |
+
</Group>
|
71 |
+
</Alert>
|
72 |
+
<Table striped highlightOnHover withTableBorder>
|
73 |
+
<Table.Thead>
|
74 |
+
<Table.Tr>
|
75 |
+
<Table.Th>Time</Table.Th>
|
76 |
+
<Table.Th>Message</Table.Th>
|
77 |
+
</Table.Tr>
|
78 |
+
</Table.Thead>
|
79 |
+
<Table.Tbody>
|
80 |
+
{logEntriesFromCurrentPage.map((entry, index) => (
|
81 |
+
<Table.Tr key={`${entry.timestamp}-${index}`}>
|
82 |
+
<Table.Td>
|
83 |
+
{new Date(entry.timestamp).toLocaleTimeString()}
|
84 |
+
</Table.Td>
|
85 |
+
<Table.Td>{entry.message}</Table.Td>
|
86 |
+
</Table.Tr>
|
87 |
+
))}
|
88 |
+
</Table.Tbody>
|
89 |
+
</Table>
|
90 |
+
<Center>
|
91 |
+
<Pagination
|
92 |
+
total={Math.ceil(logEntries.length / logEntriesPerPage)}
|
93 |
+
value={page}
|
94 |
+
onChange={setPage}
|
95 |
+
size="sm"
|
96 |
+
mt="md"
|
97 |
+
/>
|
98 |
+
</Center>
|
99 |
+
</Modal>
|
100 |
+
);
|
101 |
+
}
|
client/components/Logs/ShowLogsButton.tsx
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Button, Center, Loader, Stack, Text } from "@mantine/core";
|
2 |
+
import { Suspense, lazy, useState } from "react";
|
3 |
+
import { addLogEntry } from "../../modules/logEntries";
|
4 |
+
|
5 |
+
const LogsModal = lazy(() => import("./LogsModal"));
|
6 |
+
|
7 |
+
export default function ShowLogsButton() {
|
8 |
+
const [isLogsModalOpen, setLogsModalOpen] = useState(false);
|
9 |
+
|
10 |
+
const handleShowLogsButtonClick = () => {
|
11 |
+
addLogEntry("User opened the logs modal");
|
12 |
+
setLogsModalOpen(true);
|
13 |
+
};
|
14 |
+
|
15 |
+
const handleCloseLogsButtonClick = () => {
|
16 |
+
addLogEntry("User closed the logs modal");
|
17 |
+
setLogsModalOpen(false);
|
18 |
+
};
|
19 |
+
|
20 |
+
return (
|
21 |
+
<Stack gap="xs">
|
22 |
+
<Suspense
|
23 |
+
fallback={
|
24 |
+
<Center>
|
25 |
+
<Loader color="gray" type="bars" />
|
26 |
+
</Center>
|
27 |
+
}
|
28 |
+
>
|
29 |
+
<Button size="sm" onClick={handleShowLogsButtonClick} variant="default">
|
30 |
+
Show logs
|
31 |
+
</Button>
|
32 |
+
<Text size="xs" c="dimmed">
|
33 |
+
View session logs for debugging.
|
34 |
+
</Text>
|
35 |
+
<LogsModal
|
36 |
+
opened={isLogsModalOpen}
|
37 |
+
onClose={handleCloseLogsButtonClick}
|
38 |
+
/>
|
39 |
+
</Suspense>
|
40 |
+
</Stack>
|
41 |
+
);
|
42 |
+
}
|
client/components/Pages/AccessPage.tsx
ADDED
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Button, Container, Stack, TextInput, Title } from "@mantine/core";
|
2 |
+
import { type FormEvent, useState } from "react";
|
3 |
+
import { validateAccessKey } from "../../modules/accessKey";
|
4 |
+
import { addLogEntry } from "../../modules/logEntries";
|
5 |
+
|
6 |
+
export default function AccessPage({
|
7 |
+
onAccessKeyValid,
|
8 |
+
}: {
|
9 |
+
onAccessKeyValid: () => void;
|
10 |
+
}) {
|
11 |
+
const [accessKey, setAccessKey] = useState("");
|
12 |
+
const [error, setError] = useState("");
|
13 |
+
|
14 |
+
const handleSubmit = async (formEvent: FormEvent<HTMLFormElement>) => {
|
15 |
+
formEvent.preventDefault();
|
16 |
+
setError("");
|
17 |
+
try {
|
18 |
+
const isValid = await validateAccessKey(accessKey);
|
19 |
+
if (isValid) {
|
20 |
+
addLogEntry("Valid access key entered");
|
21 |
+
onAccessKeyValid();
|
22 |
+
} else {
|
23 |
+
setError("Invalid access key");
|
24 |
+
addLogEntry("Invalid access key attempt");
|
25 |
+
}
|
26 |
+
} catch (error) {
|
27 |
+
setError("Error validating access key");
|
28 |
+
addLogEntry(`Error validating access key: ${error}`);
|
29 |
+
}
|
30 |
+
};
|
31 |
+
|
32 |
+
return (
|
33 |
+
<Container size="xs">
|
34 |
+
<Stack p="lg" mih="100vh" justify="center">
|
35 |
+
<Title order={2} ta="center">
|
36 |
+
Access Restricted
|
37 |
+
</Title>
|
38 |
+
<form onSubmit={handleSubmit}>
|
39 |
+
<Stack gap="xs">
|
40 |
+
<TextInput
|
41 |
+
value={accessKey}
|
42 |
+
onChange={({ target }) => setAccessKey(target.value)}
|
43 |
+
placeholder="Enter your access key to continue"
|
44 |
+
required
|
45 |
+
autoFocus
|
46 |
+
error={error}
|
47 |
+
styles={{
|
48 |
+
input: {
|
49 |
+
textAlign: "center",
|
50 |
+
},
|
51 |
+
}}
|
52 |
+
/>
|
53 |
+
<Button size="xs" type="submit">
|
54 |
+
Submit
|
55 |
+
</Button>
|
56 |
+
</Stack>
|
57 |
+
</form>
|
58 |
+
</Stack>
|
59 |
+
</Container>
|
60 |
+
);
|
61 |
+
}
|
client/components/Pages/Main/MainPage.tsx
ADDED
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Center, Container, Loader, Stack } from "@mantine/core";
|
2 |
+
import { usePubSub } from "create-pubsub/react";
|
3 |
+
import { Suspense, useEffect } from "react";
|
4 |
+
import { lazy } from "react";
|
5 |
+
import { Pattern, match } from "ts-pattern";
|
6 |
+
import { addLogEntry } from "../../../modules/logEntries";
|
7 |
+
import {
|
8 |
+
queryPubSub,
|
9 |
+
searchStatePubSub,
|
10 |
+
textGenerationStatePubSub,
|
11 |
+
} from "../../../modules/pubSub";
|
12 |
+
|
13 |
+
const AiResponseSection = lazy(
|
14 |
+
() => import("../../AiResponse/AiResponseSection"),
|
15 |
+
);
|
16 |
+
const SearchResultsSection = lazy(
|
17 |
+
() => import("../../Search/Results/SearchResultsSection"),
|
18 |
+
);
|
19 |
+
const MenuButton = lazy(() => import("./Menu/MenuButton"));
|
20 |
+
const SearchForm = lazy(() => import("../../Search/Form/SearchForm"));
|
21 |
+
|
22 |
+
export default function MainPage() {
|
23 |
+
const [query, updateQuery] = usePubSub(queryPubSub);
|
24 |
+
const [searchState] = usePubSub(searchStatePubSub);
|
25 |
+
const [textGenerationState] = usePubSub(textGenerationStatePubSub);
|
26 |
+
|
27 |
+
useEffect(() => {
|
28 |
+
addLogEntry(`Search state changed to '${searchState}'`);
|
29 |
+
}, [searchState]);
|
30 |
+
|
31 |
+
useEffect(() => {
|
32 |
+
addLogEntry(`Text generation state changed to '${textGenerationState}'`);
|
33 |
+
}, [textGenerationState]);
|
34 |
+
|
35 |
+
return (
|
36 |
+
<Container>
|
37 |
+
<Stack
|
38 |
+
py="md"
|
39 |
+
mih="100vh"
|
40 |
+
justify={match(query)
|
41 |
+
.with(Pattern.string.length(0), () => "center")
|
42 |
+
.otherwise(() => undefined)}
|
43 |
+
>
|
44 |
+
<Suspense
|
45 |
+
fallback={
|
46 |
+
<Center>
|
47 |
+
<Loader type="bars" />
|
48 |
+
</Center>
|
49 |
+
}
|
50 |
+
>
|
51 |
+
<SearchForm
|
52 |
+
query={query}
|
53 |
+
updateQuery={updateQuery}
|
54 |
+
additionalButtons={<MenuButton />}
|
55 |
+
/>
|
56 |
+
</Suspense>
|
57 |
+
{match(textGenerationState)
|
58 |
+
.with(Pattern.not("idle"), () => (
|
59 |
+
<Suspense>
|
60 |
+
<AiResponseSection />
|
61 |
+
</Suspense>
|
62 |
+
))
|
63 |
+
.otherwise(() => null)}
|
64 |
+
{match(searchState)
|
65 |
+
.with(Pattern.not("idle"), () => (
|
66 |
+
<Suspense>
|
67 |
+
<SearchResultsSection />
|
68 |
+
</Suspense>
|
69 |
+
))
|
70 |
+
.otherwise(() => null)}
|
71 |
+
</Stack>
|
72 |
+
</Container>
|
73 |
+
);
|
74 |
+
}
|
client/components/Pages/Main/Menu/AISettingsForm.tsx
ADDED
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
Group,
|
3 |
+
NumberInput,
|
4 |
+
Select,
|
5 |
+
Skeleton,
|
6 |
+
Slider,
|
7 |
+
Stack,
|
8 |
+
Switch,
|
9 |
+
Text,
|
10 |
+
TextInput,
|
11 |
+
Textarea,
|
12 |
+
} from "@mantine/core";
|
13 |
+
import { useForm } from "@mantine/form";
|
14 |
+
import { IconInfoCircle } from "@tabler/icons-react";
|
15 |
+
import { usePubSub } from "create-pubsub/react";
|
16 |
+
import { Suspense, lazy, useEffect, useState } from "react";
|
17 |
+
import { Pattern, match } from "ts-pattern";
|
18 |
+
import { addLogEntry } from "../../../../modules/logEntries";
|
19 |
+
import { getOpenAiClient } from "../../../../modules/openai";
|
20 |
+
import { settingsPubSub } from "../../../../modules/pubSub";
|
21 |
+
import { defaultSettings, inferenceTypes } from "../../../../modules/settings";
|
22 |
+
import { isWebGPUAvailable } from "../../../../modules/webGpu";
|
23 |
+
|
24 |
+
const WebLlmModelSelect = lazy(
|
25 |
+
() => import("../../../AiResponse/WebLlmModelSelect"),
|
26 |
+
);
|
27 |
+
const WllamaModelSelect = lazy(
|
28 |
+
() => import("../../../AiResponse/WllamaModelSelect"),
|
29 |
+
);
|
30 |
+
|
31 |
+
export default function AISettingsForm() {
|
32 |
+
const [settings, setSettings] = usePubSub(settingsPubSub);
|
33 |
+
const [openAiModels, setOpenAiModels] = useState<
|
34 |
+
{
|
35 |
+
label: string;
|
36 |
+
value: string;
|
37 |
+
}[]
|
38 |
+
>([]);
|
39 |
+
const [openAiApiModelError, setOpenAiApiModelError] = useState<
|
40 |
+
string | undefined
|
41 |
+
>(undefined);
|
42 |
+
|
43 |
+
const form = useForm({
|
44 |
+
initialValues: settings,
|
45 |
+
onValuesChange: setSettings,
|
46 |
+
});
|
47 |
+
|
48 |
+
useEffect(() => {
|
49 |
+
async function fetchOpenAiModels() {
|
50 |
+
try {
|
51 |
+
const openai = getOpenAiClient({
|
52 |
+
baseURL: settings.openAiApiBaseUrl,
|
53 |
+
apiKey: settings.openAiApiKey,
|
54 |
+
});
|
55 |
+
const response = await openai.models.list();
|
56 |
+
const models = response.data.map((model) => ({
|
57 |
+
label: model.id,
|
58 |
+
value: model.id,
|
59 |
+
}));
|
60 |
+
setOpenAiModels(models);
|
61 |
+
setOpenAiApiModelError(undefined);
|
62 |
+
} catch (error) {
|
63 |
+
const errorMessage =
|
64 |
+
error instanceof Error ? error.message : String(error);
|
65 |
+
addLogEntry(`Error fetching OpenAI models: ${errorMessage}`);
|
66 |
+
setOpenAiModels([]);
|
67 |
+
setOpenAiApiModelError(errorMessage);
|
68 |
+
}
|
69 |
+
}
|
70 |
+
|
71 |
+
if (settings.inferenceType === "openai" && settings.openAiApiBaseUrl) {
|
72 |
+
fetchOpenAiModels();
|
73 |
+
}
|
74 |
+
}, [
|
75 |
+
settings.inferenceType,
|
76 |
+
settings.openAiApiBaseUrl,
|
77 |
+
settings.openAiApiKey,
|
78 |
+
]);
|
79 |
+
|
80 |
+
useEffect(() => {
|
81 |
+
if (openAiApiModelError === form.errors.openAiApiModel) return;
|
82 |
+
|
83 |
+
form.setFieldError("openAiApiModel", openAiApiModelError);
|
84 |
+
}, [openAiApiModelError, form.setFieldError, form.errors.openAiApiModel]);
|
85 |
+
|
86 |
+
useEffect(() => {
|
87 |
+
if (openAiModels.length > 0) {
|
88 |
+
const hasNoModelSelected = !form.values.openAiApiModel;
|
89 |
+
const isModelInvalid = !openAiModels.find(
|
90 |
+
(model) => model.value === form.values.openAiApiModel,
|
91 |
+
);
|
92 |
+
|
93 |
+
if (hasNoModelSelected || isModelInvalid) {
|
94 |
+
form.setFieldValue("openAiApiModel", openAiModels[0].value);
|
95 |
+
}
|
96 |
+
} else if (form.values.openAiApiModel) {
|
97 |
+
form.setFieldValue("openAiApiModel", "");
|
98 |
+
}
|
99 |
+
}, [openAiModels, form.setFieldValue, form.values.openAiApiModel]);
|
100 |
+
|
101 |
+
const isUsingCustomInstructions =
|
102 |
+
form.values.systemPrompt !== defaultSettings.systemPrompt;
|
103 |
+
|
104 |
+
const handleRestoreDefaultInstructions = () => {
|
105 |
+
form.setFieldValue("systemPrompt", defaultSettings.systemPrompt);
|
106 |
+
};
|
107 |
+
|
108 |
+
const suggestedCpuThreads =
|
109 |
+
(navigator.hardwareConcurrency &&
|
110 |
+
Math.max(
|
111 |
+
defaultSettings.cpuThreads,
|
112 |
+
navigator.hardwareConcurrency - 2,
|
113 |
+
)) ??
|
114 |
+
defaultSettings.cpuThreads;
|
115 |
+
|
116 |
+
return (
|
117 |
+
<Stack gap="md">
|
118 |
+
<Switch
|
119 |
+
label="AI Response"
|
120 |
+
{...form.getInputProps("enableAiResponse", {
|
121 |
+
type: "checkbox",
|
122 |
+
})}
|
123 |
+
labelPosition="left"
|
124 |
+
description="Enable or disable AI-generated responses to your queries. When disabled, you'll only see web search results."
|
125 |
+
/>
|
126 |
+
|
127 |
+
{form.values.enableAiResponse && (
|
128 |
+
<>
|
129 |
+
<Stack gap="xs" mb="md">
|
130 |
+
<Text size="sm">Search results to consider</Text>
|
131 |
+
<Text size="xs" c="dimmed">
|
132 |
+
Determines the number of search results to consider when
|
133 |
+
generating AI responses. A higher value may enhance accuracy, but
|
134 |
+
it will also increase response time.
|
135 |
+
</Text>
|
136 |
+
<Slider
|
137 |
+
{...form.getInputProps("searchResultsToConsider")}
|
138 |
+
min={0}
|
139 |
+
max={6}
|
140 |
+
marks={Array.from({ length: 7 }, (_, index) => ({
|
141 |
+
value: index,
|
142 |
+
label: index.toString(),
|
143 |
+
}))}
|
144 |
+
/>
|
145 |
+
</Stack>
|
146 |
+
|
147 |
+
<Select
|
148 |
+
{...form.getInputProps("inferenceType")}
|
149 |
+
label="AI Processing Location"
|
150 |
+
data={inferenceTypes}
|
151 |
+
allowDeselect={false}
|
152 |
+
/>
|
153 |
+
|
154 |
+
{form.values.inferenceType === "openai" && (
|
155 |
+
<>
|
156 |
+
<TextInput
|
157 |
+
{...form.getInputProps("openAiApiBaseUrl")}
|
158 |
+
label="API Base URL"
|
159 |
+
placeholder="http://localhost:11434/v1"
|
160 |
+
required
|
161 |
+
/>
|
162 |
+
<Group gap="xs">
|
163 |
+
<IconInfoCircle size={16} />
|
164 |
+
<Text size="xs" c="dimmed" flex={1}>
|
165 |
+
You may need to add{" "}
|
166 |
+
<em>
|
167 |
+
{`${self.location.protocol}//${self.location.hostname}`}
|
168 |
+
</em>{" "}
|
169 |
+
to the list of allowed network origins in your API server
|
170 |
+
settings.
|
171 |
+
</Text>
|
172 |
+
</Group>
|
173 |
+
<TextInput
|
174 |
+
{...form.getInputProps("openAiApiKey")}
|
175 |
+
label="API Key"
|
176 |
+
type="password"
|
177 |
+
description="Optional, as local API servers usually do not require it."
|
178 |
+
/>
|
179 |
+
<Select
|
180 |
+
{...form.getInputProps("openAiApiModel")}
|
181 |
+
label="API Model"
|
182 |
+
data={openAiModels}
|
183 |
+
description="Optional, as some API servers don't provide a model list."
|
184 |
+
allowDeselect={false}
|
185 |
+
disabled={openAiModels.length === 0}
|
186 |
+
searchable
|
187 |
+
/>
|
188 |
+
</>
|
189 |
+
)}
|
190 |
+
|
191 |
+
{form.values.inferenceType === "browser" && (
|
192 |
+
<>
|
193 |
+
{isWebGPUAvailable && (
|
194 |
+
<Switch
|
195 |
+
label="WebGPU"
|
196 |
+
{...form.getInputProps("enableWebGpu", {
|
197 |
+
type: "checkbox",
|
198 |
+
})}
|
199 |
+
labelPosition="left"
|
200 |
+
description="Enable or disable WebGPU usage. When disabled, the app will use the CPU instead."
|
201 |
+
/>
|
202 |
+
)}
|
203 |
+
|
204 |
+
{match([isWebGPUAvailable, form.values.enableWebGpu])
|
205 |
+
.with([true, true], () => (
|
206 |
+
<Suspense fallback={<Skeleton height={50} />}>
|
207 |
+
<WebLlmModelSelect
|
208 |
+
value={form.values.webLlmModelId}
|
209 |
+
onChange={(value) =>
|
210 |
+
form.setFieldValue("webLlmModelId", value)
|
211 |
+
}
|
212 |
+
/>
|
213 |
+
</Suspense>
|
214 |
+
))
|
215 |
+
.with([false, Pattern.any], [Pattern.any, false], () => (
|
216 |
+
<>
|
217 |
+
<Suspense fallback={<Skeleton height={50} />}>
|
218 |
+
<WllamaModelSelect
|
219 |
+
value={form.values.wllamaModelId}
|
220 |
+
onChange={(value) =>
|
221 |
+
form.setFieldValue("wllamaModelId", value)
|
222 |
+
}
|
223 |
+
/>
|
224 |
+
</Suspense>
|
225 |
+
<NumberInput
|
226 |
+
label="CPU threads to use"
|
227 |
+
description={
|
228 |
+
<>
|
229 |
+
<span>
|
230 |
+
Number of threads to use for the AI model. Lower
|
231 |
+
values will use less CPU but may take longer to
|
232 |
+
respond. A value that is too high may cause the app
|
233 |
+
to hang.
|
234 |
+
</span>
|
235 |
+
{suggestedCpuThreads > defaultSettings.cpuThreads && (
|
236 |
+
<span>
|
237 |
+
{" "}
|
238 |
+
The default value is{" "}
|
239 |
+
<Text
|
240 |
+
component="span"
|
241 |
+
size="xs"
|
242 |
+
c="blue"
|
243 |
+
style={{ cursor: "pointer" }}
|
244 |
+
onClick={() =>
|
245 |
+
form.setFieldValue(
|
246 |
+
"cpuThreads",
|
247 |
+
defaultSettings.cpuThreads,
|
248 |
+
)
|
249 |
+
}
|
250 |
+
>
|
251 |
+
{defaultSettings.cpuThreads}
|
252 |
+
</Text>
|
253 |
+
, but based on the number of logical processors in
|
254 |
+
your CPU, the suggested value is{" "}
|
255 |
+
<Text
|
256 |
+
component="span"
|
257 |
+
size="xs"
|
258 |
+
c="blue"
|
259 |
+
style={{ cursor: "pointer" }}
|
260 |
+
onClick={() =>
|
261 |
+
form.setFieldValue(
|
262 |
+
"cpuThreads",
|
263 |
+
suggestedCpuThreads,
|
264 |
+
)
|
265 |
+
}
|
266 |
+
>
|
267 |
+
{suggestedCpuThreads}
|
268 |
+
</Text>
|
269 |
+
.
|
270 |
+
</span>
|
271 |
+
)}
|
272 |
+
</>
|
273 |
+
}
|
274 |
+
min={1}
|
275 |
+
{...form.getInputProps("cpuThreads")}
|
276 |
+
/>
|
277 |
+
</>
|
278 |
+
))
|
279 |
+
.otherwise(() => null)}
|
280 |
+
</>
|
281 |
+
)}
|
282 |
+
|
283 |
+
<Textarea
|
284 |
+
label="Instructions for AI"
|
285 |
+
descriptionProps={{ component: "div" }}
|
286 |
+
description={
|
287 |
+
<>
|
288 |
+
<span>
|
289 |
+
Customize instructions for the AI to tailor its responses.
|
290 |
+
</span>
|
291 |
+
<br />
|
292 |
+
<span>For example:</span>
|
293 |
+
<ul>
|
294 |
+
<li>
|
295 |
+
Specify preferences
|
296 |
+
<ul>
|
297 |
+
<li>
|
298 |
+
<em>"use simple language"</em>
|
299 |
+
</li>
|
300 |
+
<li>
|
301 |
+
<em>"provide step-by-step explanations"</em>
|
302 |
+
</li>
|
303 |
+
</ul>
|
304 |
+
</li>
|
305 |
+
<li>
|
306 |
+
Set a response style
|
307 |
+
<ul>
|
308 |
+
<li>
|
309 |
+
<em>"answer in a friendly tone"</em>
|
310 |
+
</li>
|
311 |
+
<li>
|
312 |
+
<em>"write your response in Spanish"</em>
|
313 |
+
</li>
|
314 |
+
</ul>
|
315 |
+
</li>
|
316 |
+
<li>
|
317 |
+
Provide context about the audience
|
318 |
+
<ul>
|
319 |
+
<li>
|
320 |
+
<em>"you're talking to a high school student"</em>
|
321 |
+
</li>
|
322 |
+
<li>
|
323 |
+
<em>
|
324 |
+
"consider that your audience is composed of
|
325 |
+
professionals in the field of graphic design"
|
326 |
+
</em>
|
327 |
+
</li>
|
328 |
+
</ul>
|
329 |
+
</li>
|
330 |
+
</ul>
|
331 |
+
<span>
|
332 |
+
The special tag <em>{"{{searchResults}}"}</em> will be
|
333 |
+
replaced with the search results, while{" "}
|
334 |
+
<em>{"{{dateTime}}"}</em> will be replaced with the current
|
335 |
+
date and time.
|
336 |
+
</span>
|
337 |
+
{isUsingCustomInstructions && (
|
338 |
+
<>
|
339 |
+
<br />
|
340 |
+
<br />
|
341 |
+
<span>
|
342 |
+
Currently, you're using custom instructions. If you ever
|
343 |
+
need to restore the default instructions, you can do so by
|
344 |
+
clicking
|
345 |
+
</span>{" "}
|
346 |
+
<Text
|
347 |
+
component="span"
|
348 |
+
size="xs"
|
349 |
+
c="blue"
|
350 |
+
style={{ cursor: "pointer" }}
|
351 |
+
onClick={handleRestoreDefaultInstructions}
|
352 |
+
>
|
353 |
+
here
|
354 |
+
</Text>
|
355 |
+
<span>.</span>
|
356 |
+
</>
|
357 |
+
)}
|
358 |
+
</>
|
359 |
+
}
|
360 |
+
autosize
|
361 |
+
maxRows={10}
|
362 |
+
{...form.getInputProps("systemPrompt")}
|
363 |
+
/>
|
364 |
+
</>
|
365 |
+
)}
|
366 |
+
</Stack>
|
367 |
+
);
|
368 |
+
}
|
client/components/Pages/Main/Menu/ActionsForm.tsx
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Stack } from "@mantine/core";
|
2 |
+
import { Suspense, lazy } from "react";
|
3 |
+
|
4 |
+
const ClearDataButton = lazy(() => import("./ClearDataButton"));
|
5 |
+
const ShowLogsButton = lazy(() => import("../../../Logs/ShowLogsButton"));
|
6 |
+
|
7 |
+
export default function ActionsForm() {
|
8 |
+
return (
|
9 |
+
<Stack gap="lg">
|
10 |
+
<Suspense>
|
11 |
+
<ClearDataButton />
|
12 |
+
</Suspense>
|
13 |
+
<Suspense>
|
14 |
+
<ShowLogsButton />
|
15 |
+
</Suspense>
|
16 |
+
</Stack>
|
17 |
+
);
|
18 |
+
}
|
client/components/Pages/Main/Menu/ClearDataButton.tsx
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Button, Stack, Text } from "@mantine/core";
|
2 |
+
import { useState } from "react";
|
3 |
+
import { useLocation } from "wouter";
|
4 |
+
import { addLogEntry } from "../../../../modules/logEntries";
|
5 |
+
|
6 |
+
export default function ClearDataButton() {
|
7 |
+
const [isClearingData, setIsClearingData] = useState(false);
|
8 |
+
const [hasClearedData, setHasClearedData] = useState(false);
|
9 |
+
const [, navigate] = useLocation();
|
10 |
+
|
11 |
+
const handleClearDataButtonClick = async () => {
|
12 |
+
const sureToDelete = self.confirm(
|
13 |
+
"Are you sure you want to reset the settings and delete all files in cache?",
|
14 |
+
);
|
15 |
+
|
16 |
+
if (!sureToDelete) return;
|
17 |
+
|
18 |
+
addLogEntry("User initiated data clearing");
|
19 |
+
|
20 |
+
setIsClearingData(true);
|
21 |
+
|
22 |
+
self.localStorage.clear();
|
23 |
+
|
24 |
+
for (const cacheName of await self.caches.keys()) {
|
25 |
+
await self.caches.delete(cacheName);
|
26 |
+
}
|
27 |
+
|
28 |
+
for (const databaseInfo of await self.indexedDB.databases()) {
|
29 |
+
if (databaseInfo.name) self.indexedDB.deleteDatabase(databaseInfo.name);
|
30 |
+
}
|
31 |
+
|
32 |
+
setIsClearingData(false);
|
33 |
+
|
34 |
+
setHasClearedData(true);
|
35 |
+
|
36 |
+
addLogEntry("All data cleared successfully");
|
37 |
+
|
38 |
+
navigate("/", { replace: true });
|
39 |
+
|
40 |
+
self.location.reload();
|
41 |
+
};
|
42 |
+
|
43 |
+
return (
|
44 |
+
<Stack gap="xs">
|
45 |
+
<Button
|
46 |
+
onClick={handleClearDataButtonClick}
|
47 |
+
variant="default"
|
48 |
+
loading={isClearingData}
|
49 |
+
loaderProps={{ type: "bars" }}
|
50 |
+
disabled={hasClearedData}
|
51 |
+
>
|
52 |
+
{hasClearedData ? "Data cleared" : "Clear all data"}
|
53 |
+
</Button>
|
54 |
+
<Text size="xs" c="dimmed">
|
55 |
+
Reset settings and delete all files in cache to free up space.
|
56 |
+
</Text>
|
57 |
+
</Stack>
|
58 |
+
);
|
59 |
+
}
|
client/components/Pages/Main/Menu/InterfaceSettingsForm.tsx
ADDED
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
Stack,
|
3 |
+
Switch,
|
4 |
+
useComputedColorScheme,
|
5 |
+
useMantineColorScheme,
|
6 |
+
} from "@mantine/core";
|
7 |
+
import { useForm } from "@mantine/form";
|
8 |
+
import { usePubSub } from "create-pubsub/react";
|
9 |
+
import { settingsPubSub } from "../../../../modules/pubSub";
|
10 |
+
|
11 |
+
export default function InterfaceSettingsForm() {
|
12 |
+
const [settings, setSettings] = usePubSub(settingsPubSub);
|
13 |
+
const form = useForm({
|
14 |
+
initialValues: settings,
|
15 |
+
onValuesChange: setSettings,
|
16 |
+
});
|
17 |
+
const { setColorScheme } = useMantineColorScheme();
|
18 |
+
const computedColorScheme = useComputedColorScheme("light");
|
19 |
+
|
20 |
+
const toggleColorScheme = () => {
|
21 |
+
setColorScheme(computedColorScheme === "dark" ? "light" : "dark");
|
22 |
+
};
|
23 |
+
|
24 |
+
return (
|
25 |
+
<Stack gap="md">
|
26 |
+
<Switch
|
27 |
+
label="Dark Mode"
|
28 |
+
checked={computedColorScheme === "dark"}
|
29 |
+
onChange={toggleColorScheme}
|
30 |
+
labelPosition="left"
|
31 |
+
description="Enable or disable the dark color scheme."
|
32 |
+
styles={{ labelWrapper: { width: "100%" } }}
|
33 |
+
/>
|
34 |
+
|
35 |
+
<Switch
|
36 |
+
{...form.getInputProps("enableImageSearch", {
|
37 |
+
type: "checkbox",
|
38 |
+
})}
|
39 |
+
label="Image Search"
|
40 |
+
labelPosition="left"
|
41 |
+
description="Enable or disable image search results. When enabled, relevant images will be displayed alongside web search results."
|
42 |
+
/>
|
43 |
+
|
44 |
+
<Switch
|
45 |
+
{...form.getInputProps("enterToSubmit", {
|
46 |
+
type: "checkbox",
|
47 |
+
})}
|
48 |
+
label="Enter to Submit"
|
49 |
+
labelPosition="left"
|
50 |
+
description="Enable or disable using Enter key to submit the search query. When disabled, you'll need to click the Search button or use Shift+Enter to submit."
|
51 |
+
/>
|
52 |
+
</Stack>
|
53 |
+
);
|
54 |
+
}
|
client/components/Pages/Main/Menu/MenuButton.tsx
ADDED
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Button } from "@mantine/core";
|
2 |
+
import { Suspense, lazy, useCallback, useEffect, useState } from "react";
|
3 |
+
import { addLogEntry } from "../../../../modules/logEntries";
|
4 |
+
|
5 |
+
const MenuDrawer = lazy(() => import("./MenuDrawer"));
|
6 |
+
|
7 |
+
export default function MenuButton() {
|
8 |
+
const [isDrawerOpen, setDrawerOpen] = useState(false);
|
9 |
+
const [isDrawerLoaded, setDrawerLoaded] = useState(false);
|
10 |
+
|
11 |
+
const openDrawer = useCallback(() => {
|
12 |
+
setDrawerOpen(true);
|
13 |
+
addLogEntry("User opened the menu");
|
14 |
+
}, []);
|
15 |
+
|
16 |
+
const closeDrawer = useCallback(() => {
|
17 |
+
setDrawerOpen(false);
|
18 |
+
addLogEntry("User closed the menu");
|
19 |
+
}, []);
|
20 |
+
|
21 |
+
const handleDrawerLoad = useCallback(() => {
|
22 |
+
if (!isDrawerLoaded) {
|
23 |
+
addLogEntry("Menu drawer loaded");
|
24 |
+
setDrawerLoaded(true);
|
25 |
+
}
|
26 |
+
}, [isDrawerLoaded]);
|
27 |
+
|
28 |
+
return (
|
29 |
+
<>
|
30 |
+
<Button
|
31 |
+
size="xs"
|
32 |
+
onClick={openDrawer}
|
33 |
+
variant="default"
|
34 |
+
loading={isDrawerOpen && !isDrawerLoaded}
|
35 |
+
>
|
36 |
+
Menu
|
37 |
+
</Button>
|
38 |
+
{(isDrawerOpen || isDrawerLoaded) && (
|
39 |
+
<Suspense fallback={<SuspenseListener onUnload={handleDrawerLoad} />}>
|
40 |
+
<MenuDrawer onClose={closeDrawer} opened={isDrawerOpen} />
|
41 |
+
</Suspense>
|
42 |
+
)}
|
43 |
+
</>
|
44 |
+
);
|
45 |
+
}
|
46 |
+
|
47 |
+
function SuspenseListener({ onUnload }: { onUnload: () => void }) {
|
48 |
+
useEffect(() => {
|
49 |
+
return () => onUnload();
|
50 |
+
}, [onUnload]);
|
51 |
+
|
52 |
+
return null;
|
53 |
+
}
|
client/components/Pages/Main/Menu/MenuDrawer.tsx
ADDED
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
Accordion,
|
3 |
+
ActionIcon,
|
4 |
+
Center,
|
5 |
+
Drawer,
|
6 |
+
type DrawerProps,
|
7 |
+
FocusTrap,
|
8 |
+
Group,
|
9 |
+
HoverCard,
|
10 |
+
Stack,
|
11 |
+
} from "@mantine/core";
|
12 |
+
import { IconBrandGithub } from "@tabler/icons-react";
|
13 |
+
import prettyMilliseconds from "pretty-ms";
|
14 |
+
import { Suspense, lazy } from "react";
|
15 |
+
import { repository } from "../../../../../package.json";
|
16 |
+
import { addLogEntry } from "../../../../modules/logEntries";
|
17 |
+
import { getSemanticVersion } from "../../../../modules/stringFormatters";
|
18 |
+
|
19 |
+
const AISettingsForm = lazy(() => import("./AISettingsForm"));
|
20 |
+
const ActionsForm = lazy(() => import("./ActionsForm"));
|
21 |
+
const InterfaceSettingsForm = lazy(() => import("./InterfaceSettingsForm"));
|
22 |
+
|
23 |
+
export default function MenuDrawer(drawerProps: DrawerProps) {
|
24 |
+
const repoName = repository.url.split("/").pop();
|
25 |
+
|
26 |
+
return (
|
27 |
+
<Drawer
|
28 |
+
{...drawerProps}
|
29 |
+
position="right"
|
30 |
+
size="md"
|
31 |
+
title={
|
32 |
+
<Group gap="xs">
|
33 |
+
<ActionIcon
|
34 |
+
variant="subtle"
|
35 |
+
component="a"
|
36 |
+
color="var(--mantine-color-text)"
|
37 |
+
href={repository.url}
|
38 |
+
target="_blank"
|
39 |
+
onClick={() => addLogEntry("User clicked the GitHub link")}
|
40 |
+
>
|
41 |
+
<IconBrandGithub size={16} />
|
42 |
+
</ActionIcon>
|
43 |
+
<HoverCard shadow="md" withArrow>
|
44 |
+
<HoverCard.Target>
|
45 |
+
<Center>{repoName}</Center>
|
46 |
+
</HoverCard.Target>
|
47 |
+
<HoverCard.Dropdown>
|
48 |
+
<Stack gap="xs">
|
49 |
+
<Center>{repoName}</Center>
|
50 |
+
<Center>
|
51 |
+
{`v${getSemanticVersion(VITE_BUILD_DATE_TIME)}+${VITE_COMMIT_SHORT_HASH}`}
|
52 |
+
</Center>
|
53 |
+
<Center>
|
54 |
+
Released{" "}
|
55 |
+
{prettyMilliseconds(
|
56 |
+
new Date().getTime() -
|
57 |
+
new Date(VITE_BUILD_DATE_TIME).getTime(),
|
58 |
+
{
|
59 |
+
compact: true,
|
60 |
+
verbose: true,
|
61 |
+
},
|
62 |
+
)}{" "}
|
63 |
+
ago
|
64 |
+
</Center>
|
65 |
+
</Stack>
|
66 |
+
</HoverCard.Dropdown>
|
67 |
+
</HoverCard>
|
68 |
+
</Group>
|
69 |
+
}
|
70 |
+
>
|
71 |
+
<FocusTrap.InitialFocus />
|
72 |
+
<Drawer.Body>
|
73 |
+
<Accordion variant="separated" multiple>
|
74 |
+
<Accordion.Item value="aiSettings">
|
75 |
+
<Accordion.Control>AI Settings</Accordion.Control>
|
76 |
+
<Accordion.Panel>
|
77 |
+
<Suspense>
|
78 |
+
<AISettingsForm />
|
79 |
+
</Suspense>
|
80 |
+
</Accordion.Panel>
|
81 |
+
</Accordion.Item>
|
82 |
+
<Accordion.Item value="interfaceSettings">
|
83 |
+
<Accordion.Control>Interface Settings</Accordion.Control>
|
84 |
+
<Accordion.Panel>
|
85 |
+
<Suspense>
|
86 |
+
<InterfaceSettingsForm />
|
87 |
+
</Suspense>
|
88 |
+
</Accordion.Panel>
|
89 |
+
</Accordion.Item>
|
90 |
+
<Accordion.Item value="actions">
|
91 |
+
<Accordion.Control>Actions</Accordion.Control>
|
92 |
+
<Accordion.Panel>
|
93 |
+
<Suspense>
|
94 |
+
<ActionsForm />
|
95 |
+
</Suspense>
|
96 |
+
</Accordion.Panel>
|
97 |
+
</Accordion.Item>
|
98 |
+
</Accordion>
|
99 |
+
</Drawer.Body>
|
100 |
+
</Drawer>
|
101 |
+
);
|
102 |
+
}
|
client/components/Search/Form/SearchForm.tsx
ADDED
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Button, Group, Stack, Textarea } from "@mantine/core";
|
2 |
+
import { usePubSub } from "create-pubsub/react";
|
3 |
+
import {
|
4 |
+
type ChangeEvent,
|
5 |
+
type KeyboardEvent,
|
6 |
+
type ReactNode,
|
7 |
+
useCallback,
|
8 |
+
useEffect,
|
9 |
+
useRef,
|
10 |
+
useState,
|
11 |
+
} from "react";
|
12 |
+
import { Pattern, match } from "ts-pattern";
|
13 |
+
import { useLocation } from "wouter";
|
14 |
+
import { addLogEntry } from "../../../modules/logEntries";
|
15 |
+
import { postMessageToParentWindow } from "../../../modules/parentWindow";
|
16 |
+
import { settingsPubSub } from "../../../modules/pubSub";
|
17 |
+
import { getRandomQuerySuggestion } from "../../../modules/querySuggestions";
|
18 |
+
import { sleepUntilIdle } from "../../../modules/sleep";
|
19 |
+
import { searchAndRespond } from "../../../modules/textGeneration";
|
20 |
+
|
21 |
+
export default function SearchForm({
|
22 |
+
query,
|
23 |
+
updateQuery,
|
24 |
+
additionalButtons,
|
25 |
+
}: {
|
26 |
+
query: string;
|
27 |
+
updateQuery: (query: string) => void;
|
28 |
+
additionalButtons?: ReactNode;
|
29 |
+
}) {
|
30 |
+
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
31 |
+
const [textAreaValue, setTextAreaValue] = useState(query);
|
32 |
+
const defaultSuggestedQuery = "Anything you need!";
|
33 |
+
const [suggestedQuery, setSuggestedQuery] = useState(defaultSuggestedQuery);
|
34 |
+
const [, navigate] = useLocation();
|
35 |
+
const [settings] = usePubSub(settingsPubSub);
|
36 |
+
|
37 |
+
useEffect(() => {
|
38 |
+
sleepUntilIdle().then(() => {
|
39 |
+
searchAndRespond();
|
40 |
+
});
|
41 |
+
}, []);
|
42 |
+
|
43 |
+
useEffect(() => {
|
44 |
+
getRandomQuerySuggestion().then((querySuggestion) => {
|
45 |
+
setSuggestedQuery(querySuggestion);
|
46 |
+
});
|
47 |
+
}, []);
|
48 |
+
|
49 |
+
const handleInputChange = async (event: ChangeEvent<HTMLTextAreaElement>) => {
|
50 |
+
const text = event.target.value;
|
51 |
+
|
52 |
+
setTextAreaValue(text);
|
53 |
+
|
54 |
+
if (text.length === 0) {
|
55 |
+
setSuggestedQuery(await getRandomQuerySuggestion());
|
56 |
+
}
|
57 |
+
};
|
58 |
+
|
59 |
+
const handleClearButtonClick = async () => {
|
60 |
+
setSuggestedQuery(await getRandomQuerySuggestion());
|
61 |
+
setTextAreaValue("");
|
62 |
+
textAreaRef.current?.focus();
|
63 |
+
addLogEntry("User cleaned the search query field");
|
64 |
+
};
|
65 |
+
|
66 |
+
const startSearching = useCallback(() => {
|
67 |
+
const queryToEncode = match(textAreaValue.trim())
|
68 |
+
.with(Pattern.string.minLength(1), () => textAreaValue)
|
69 |
+
.otherwise(() => suggestedQuery);
|
70 |
+
|
71 |
+
setTextAreaValue(queryToEncode);
|
72 |
+
|
73 |
+
const queryString = `q=${encodeURIComponent(queryToEncode)}`;
|
74 |
+
|
75 |
+
postMessageToParentWindow({ queryString, hash: "" });
|
76 |
+
|
77 |
+
navigate(`/?${queryString}`, { replace: true });
|
78 |
+
|
79 |
+
updateQuery(queryToEncode);
|
80 |
+
|
81 |
+
searchAndRespond();
|
82 |
+
|
83 |
+
addLogEntry(
|
84 |
+
`User submitted a search with ${queryToEncode.length} characters length`,
|
85 |
+
);
|
86 |
+
}, [textAreaValue, suggestedQuery, updateQuery, navigate]);
|
87 |
+
|
88 |
+
const handleSubmit = (event: { preventDefault: () => void }) => {
|
89 |
+
event.preventDefault();
|
90 |
+
startSearching();
|
91 |
+
};
|
92 |
+
|
93 |
+
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
94 |
+
match([event, settings.enterToSubmit])
|
95 |
+
.with(
|
96 |
+
[{ code: "Enter", shiftKey: false }, true],
|
97 |
+
[{ code: "Enter", shiftKey: true }, false],
|
98 |
+
() => handleSubmit(event),
|
99 |
+
)
|
100 |
+
.otherwise(() => undefined);
|
101 |
+
};
|
102 |
+
|
103 |
+
return (
|
104 |
+
<form onSubmit={handleSubmit} style={{ width: "100%" }}>
|
105 |
+
<Stack gap="xs">
|
106 |
+
<Textarea
|
107 |
+
value={textAreaValue}
|
108 |
+
placeholder={suggestedQuery}
|
109 |
+
ref={textAreaRef}
|
110 |
+
onKeyDown={handleKeyDown}
|
111 |
+
onChange={handleInputChange}
|
112 |
+
autosize
|
113 |
+
minRows={1}
|
114 |
+
maxRows={8}
|
115 |
+
autoFocus
|
116 |
+
/>
|
117 |
+
<Group gap="xs">
|
118 |
+
{match(textAreaValue)
|
119 |
+
.with(Pattern.string.minLength(1), () => (
|
120 |
+
<Button
|
121 |
+
size="xs"
|
122 |
+
onClick={handleClearButtonClick}
|
123 |
+
variant="default"
|
124 |
+
>
|
125 |
+
Clear
|
126 |
+
</Button>
|
127 |
+
))
|
128 |
+
.otherwise(() => null)}
|
129 |
+
<Button size="xs" type="submit" variant="default" flex={1}>
|
130 |
+
Search
|
131 |
+
</Button>
|
132 |
+
{additionalButtons}
|
133 |
+
</Group>
|
134 |
+
</Stack>
|
135 |
+
</form>
|
136 |
+
);
|
137 |
+
}
|
client/components/Search/Results/Graphical/ImageResultsList.tsx
ADDED
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Carousel } from "@mantine/carousel";
|
2 |
+
import { Button, Group, Stack, Text, Transition, rem } from "@mantine/core";
|
3 |
+
import { useEffect, useState } from "react";
|
4 |
+
import type { SearchResults } from "../../../../modules/search";
|
5 |
+
import "@mantine/carousel/styles.css";
|
6 |
+
import Lightbox from "yet-another-react-lightbox";
|
7 |
+
import Captions from "yet-another-react-lightbox/plugins/captions";
|
8 |
+
import "yet-another-react-lightbox/styles.css";
|
9 |
+
import "yet-another-react-lightbox/plugins/captions.css";
|
10 |
+
import { addLogEntry } from "../../../../modules/logEntries";
|
11 |
+
import { getHostname } from "../../../../modules/stringFormatters";
|
12 |
+
|
13 |
+
export default function ImageResultsList({
|
14 |
+
imageResults,
|
15 |
+
}: {
|
16 |
+
imageResults: SearchResults["imageResults"];
|
17 |
+
}) {
|
18 |
+
const [isLightboxOpen, setLightboxOpen] = useState(false);
|
19 |
+
const [lightboxIndex, setLightboxIndex] = useState(0);
|
20 |
+
const [isMounted, setMounted] = useState(false);
|
21 |
+
useEffect(() => setMounted(true), []);
|
22 |
+
|
23 |
+
const handleImageClick = (index: number) => {
|
24 |
+
setLightboxIndex(index);
|
25 |
+
setLightboxOpen(true);
|
26 |
+
};
|
27 |
+
|
28 |
+
const imageStyle = {
|
29 |
+
objectFit: "cover",
|
30 |
+
height: rem(180),
|
31 |
+
width: rem(240),
|
32 |
+
borderRadius: rem(4),
|
33 |
+
border: `${rem(2)} solid var(--mantine-color-default-border)`,
|
34 |
+
cursor: "zoom-in",
|
35 |
+
} as const;
|
36 |
+
|
37 |
+
return (
|
38 |
+
<>
|
39 |
+
<Carousel slideSize="0" slideGap="xs" align="start" dragFree loop>
|
40 |
+
{imageResults.map(([title, sourceUrl, thumbnailUrl], index) => (
|
41 |
+
<Transition
|
42 |
+
key={`${title}-${sourceUrl}-${thumbnailUrl}`}
|
43 |
+
mounted={isMounted}
|
44 |
+
transition="fade"
|
45 |
+
timingFunction="ease"
|
46 |
+
enterDelay={index * 250}
|
47 |
+
duration={1500}
|
48 |
+
>
|
49 |
+
{(styles) => (
|
50 |
+
<Carousel.Slide style={styles}>
|
51 |
+
<img
|
52 |
+
alt={title}
|
53 |
+
src={thumbnailUrl}
|
54 |
+
onClick={() => handleImageClick(index)}
|
55 |
+
onKeyDown={(e) => {
|
56 |
+
if (e.key === "Enter") {
|
57 |
+
handleImageClick(index);
|
58 |
+
}
|
59 |
+
}}
|
60 |
+
style={imageStyle}
|
61 |
+
/>
|
62 |
+
</Carousel.Slide>
|
63 |
+
)}
|
64 |
+
</Transition>
|
65 |
+
))}
|
66 |
+
</Carousel>
|
67 |
+
<Lightbox
|
68 |
+
open={isLightboxOpen}
|
69 |
+
close={() => setLightboxOpen(false)}
|
70 |
+
plugins={[Captions]}
|
71 |
+
index={lightboxIndex}
|
72 |
+
slides={imageResults.map(([title, url, thumbnailUrl, sourceUrl]) => ({
|
73 |
+
src: thumbnailUrl,
|
74 |
+
description: (
|
75 |
+
<Stack align="center" gap="md">
|
76 |
+
{title && (
|
77 |
+
<Text component="cite" ta="center">
|
78 |
+
{title}
|
79 |
+
</Text>
|
80 |
+
)}
|
81 |
+
<Group align="center" justify="center" gap="xs">
|
82 |
+
<Button
|
83 |
+
variant="subtle"
|
84 |
+
component="a"
|
85 |
+
size="xs"
|
86 |
+
href={sourceUrl}
|
87 |
+
target="_blank"
|
88 |
+
title="Click to see the image in full size"
|
89 |
+
rel="noopener noreferrer"
|
90 |
+
onClick={() => {
|
91 |
+
addLogEntry("User visited an image result in full size");
|
92 |
+
}}
|
93 |
+
>
|
94 |
+
View in full resolution
|
95 |
+
</Button>
|
96 |
+
<Button
|
97 |
+
variant="subtle"
|
98 |
+
component="a"
|
99 |
+
href={url}
|
100 |
+
target="_blank"
|
101 |
+
size="xs"
|
102 |
+
title="Click to visit the page where the image was found"
|
103 |
+
rel="noopener noreferrer"
|
104 |
+
onClick={() => {
|
105 |
+
addLogEntry("User visited an image result source");
|
106 |
+
}}
|
107 |
+
>
|
108 |
+
Visit {getHostname(url)}
|
109 |
+
</Button>
|
110 |
+
</Group>
|
111 |
+
</Stack>
|
112 |
+
),
|
113 |
+
}))}
|
114 |
+
/>
|
115 |
+
</>
|
116 |
+
);
|
117 |
+
}
|
client/components/Search/Results/SearchResultsSection.tsx
ADDED
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
Alert,
|
3 |
+
AspectRatio,
|
4 |
+
Divider,
|
5 |
+
Group,
|
6 |
+
Skeleton,
|
7 |
+
Space,
|
8 |
+
Stack,
|
9 |
+
em,
|
10 |
+
} from "@mantine/core";
|
11 |
+
import { useMediaQuery } from "@mantine/hooks";
|
12 |
+
import { IconInfoCircle } from "@tabler/icons-react";
|
13 |
+
import { usePubSub } from "create-pubsub/react";
|
14 |
+
import { Suspense, lazy, useMemo } from "react";
|
15 |
+
import { Pattern, match } from "ts-pattern";
|
16 |
+
import {
|
17 |
+
searchResultsPubSub,
|
18 |
+
searchStatePubSub,
|
19 |
+
settingsPubSub,
|
20 |
+
} from "../../../modules/pubSub";
|
21 |
+
import type { SearchResults } from "../../../modules/search";
|
22 |
+
import type { Settings } from "../../../modules/settings";
|
23 |
+
|
24 |
+
const ImageResultsList = lazy(() => import("./Graphical/ImageResultsList"));
|
25 |
+
const SearchResultsList = lazy(() => import("./Textual/SearchResultsList"));
|
26 |
+
|
27 |
+
export default function SearchResultsSection() {
|
28 |
+
const [searchResults] = usePubSub(searchResultsPubSub);
|
29 |
+
const [searchState] = usePubSub(searchStatePubSub);
|
30 |
+
const [settings] = usePubSub(settingsPubSub);
|
31 |
+
|
32 |
+
return useMemo(
|
33 |
+
() =>
|
34 |
+
match(searchState)
|
35 |
+
.with("running", () => <RunningSearchContent />)
|
36 |
+
.with("failed", () => <FailedSearchContent />)
|
37 |
+
.with("completed", () => (
|
38 |
+
<CompletedSearchContent
|
39 |
+
searchResults={searchResults}
|
40 |
+
settings={settings}
|
41 |
+
/>
|
42 |
+
))
|
43 |
+
.otherwise(() => null),
|
44 |
+
[searchState, searchResults, settings],
|
45 |
+
);
|
46 |
+
}
|
47 |
+
|
48 |
+
function RunningSearchContent() {
|
49 |
+
const hasSmallScreen = useMediaQuery(`(max-width: ${em(530)})`);
|
50 |
+
const numberOfSquareSkeletons = hasSmallScreen ? 4 : 6;
|
51 |
+
const skeletonIds = Array.from(
|
52 |
+
{ length: numberOfSquareSkeletons },
|
53 |
+
(_, i) => `skeleton-${i}`,
|
54 |
+
);
|
55 |
+
|
56 |
+
return (
|
57 |
+
<>
|
58 |
+
<Divider
|
59 |
+
mb="sm"
|
60 |
+
variant="dashed"
|
61 |
+
labelPosition="center"
|
62 |
+
label="Searching the web..."
|
63 |
+
/>
|
64 |
+
<Stack>
|
65 |
+
<Group>
|
66 |
+
{skeletonIds.map((id) => (
|
67 |
+
<AspectRatio key={id} ratio={1} flex={1}>
|
68 |
+
<Skeleton />
|
69 |
+
</AspectRatio>
|
70 |
+
))}
|
71 |
+
</Group>
|
72 |
+
<Stack>
|
73 |
+
<Skeleton height={8} radius="xl" />
|
74 |
+
<Skeleton height={8} width="87%" radius="xl" />
|
75 |
+
<Skeleton height={8} radius="xl" />
|
76 |
+
<Skeleton height={8} width="70%" radius="xl" />
|
77 |
+
<Skeleton height={8} radius="xl" />
|
78 |
+
<Skeleton height={8} width="52%" radius="xl" />
|
79 |
+
<Skeleton height={8} radius="xl" />
|
80 |
+
<Skeleton height={8} width="63%" radius="xl" />
|
81 |
+
</Stack>
|
82 |
+
</Stack>
|
83 |
+
</>
|
84 |
+
);
|
85 |
+
}
|
86 |
+
|
87 |
+
function FailedSearchContent() {
|
88 |
+
return (
|
89 |
+
<>
|
90 |
+
<Divider
|
91 |
+
mb="sm"
|
92 |
+
variant="dashed"
|
93 |
+
labelPosition="center"
|
94 |
+
label="Search Results"
|
95 |
+
/>
|
96 |
+
<Alert
|
97 |
+
variant="light"
|
98 |
+
color="yellow"
|
99 |
+
title="No results found"
|
100 |
+
icon={<IconInfoCircle />}
|
101 |
+
>
|
102 |
+
It looks like your current search did not return any results. Try
|
103 |
+
refining your search by adding more keywords or rephrasing your query.
|
104 |
+
</Alert>
|
105 |
+
</>
|
106 |
+
);
|
107 |
+
}
|
108 |
+
|
109 |
+
function CompletedSearchContent({
|
110 |
+
searchResults,
|
111 |
+
settings,
|
112 |
+
}: {
|
113 |
+
searchResults: SearchResults;
|
114 |
+
settings: Settings;
|
115 |
+
}) {
|
116 |
+
return (
|
117 |
+
<>
|
118 |
+
<Divider variant="dashed" labelPosition="center" label="Search Results" />
|
119 |
+
{match([settings.enableImageSearch, searchResults.imageResults.length])
|
120 |
+
.with([true, Pattern.number.positive()], () => (
|
121 |
+
<Suspense>
|
122 |
+
<ImageResultsList imageResults={searchResults.imageResults} />
|
123 |
+
<Space h={8} />
|
124 |
+
</Suspense>
|
125 |
+
))
|
126 |
+
.otherwise(() => null)}
|
127 |
+
<Suspense>
|
128 |
+
<SearchResultsList searchResults={searchResults.textResults} />
|
129 |
+
</Suspense>
|
130 |
+
</>
|
131 |
+
);
|
132 |
+
}
|
client/components/Search/Results/Textual/SearchResultsList.tsx
ADDED
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
Flex,
|
3 |
+
Stack,
|
4 |
+
Text,
|
5 |
+
Tooltip,
|
6 |
+
Transition,
|
7 |
+
UnstyledButton,
|
8 |
+
em,
|
9 |
+
} from "@mantine/core";
|
10 |
+
import { useMediaQuery } from "@mantine/hooks";
|
11 |
+
import { useEffect, useState } from "react";
|
12 |
+
import { addLogEntry } from "../../../../modules/logEntries";
|
13 |
+
import type { SearchResults } from "../../../../modules/search";
|
14 |
+
import { getHostname } from "../../../../modules/stringFormatters";
|
15 |
+
|
16 |
+
export default function SearchResultsList({
|
17 |
+
searchResults,
|
18 |
+
}: {
|
19 |
+
searchResults: SearchResults["textResults"];
|
20 |
+
}) {
|
21 |
+
const shouldDisplayDomainBelowTitle = useMediaQuery(
|
22 |
+
`(max-width: ${em(720)})`,
|
23 |
+
);
|
24 |
+
const [isMounted, setMounted] = useState(false);
|
25 |
+
|
26 |
+
useEffect(() => setMounted(true), []);
|
27 |
+
|
28 |
+
return (
|
29 |
+
<Stack gap={40}>
|
30 |
+
{searchResults.map(([title, snippet, url], index) => (
|
31 |
+
<Transition
|
32 |
+
key={url}
|
33 |
+
mounted={isMounted}
|
34 |
+
transition="fade"
|
35 |
+
timingFunction="ease"
|
36 |
+
enterDelay={index * 200}
|
37 |
+
duration={750}
|
38 |
+
>
|
39 |
+
{(styles) => (
|
40 |
+
<Stack gap={16} style={styles}>
|
41 |
+
<Flex
|
42 |
+
gap={shouldDisplayDomainBelowTitle ? 0 : 16}
|
43 |
+
justify="space-between"
|
44 |
+
align="flex-start"
|
45 |
+
direction={shouldDisplayDomainBelowTitle ? "column" : "row"}
|
46 |
+
>
|
47 |
+
<UnstyledButton
|
48 |
+
variant="transparent"
|
49 |
+
component="a"
|
50 |
+
href={url}
|
51 |
+
target="_blank"
|
52 |
+
onClick={() => {
|
53 |
+
addLogEntry("User clicked a text result");
|
54 |
+
}}
|
55 |
+
>
|
56 |
+
<Text fw="bold" c="var(--mantine-color-blue-light-color)">
|
57 |
+
{title}
|
58 |
+
</Text>
|
59 |
+
</UnstyledButton>
|
60 |
+
<Tooltip label={url}>
|
61 |
+
<UnstyledButton
|
62 |
+
variant="transparent"
|
63 |
+
component="a"
|
64 |
+
href={url}
|
65 |
+
target="_blank"
|
66 |
+
fs="italic"
|
67 |
+
ta="end"
|
68 |
+
onClick={() => {
|
69 |
+
addLogEntry("User clicked a text result");
|
70 |
+
}}
|
71 |
+
>
|
72 |
+
{getHostname(url)}
|
73 |
+
</UnstyledButton>
|
74 |
+
</Tooltip>
|
75 |
+
</Flex>
|
76 |
+
<Text size="sm" c="dimmed" style={{ wordWrap: "break-word" }}>
|
77 |
+
{snippet}
|
78 |
+
</Text>
|
79 |
+
</Stack>
|
80 |
+
)}
|
81 |
+
</Transition>
|
82 |
+
))}
|
83 |
+
</Stack>
|
84 |
+
);
|
85 |
+
}
|
client/index.html
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!doctype html>
|
2 |
+
<html lang="en">
|
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 rel="icon" href="/favicon.png" />
|
32 |
+
</head>
|
33 |
+
<body>
|
34 |
+
<script type="module" src="./index.tsx"></script>
|
35 |
+
</body>
|
36 |
+
</html>
|
client/index.tsx
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createRoot } from "react-dom/client";
|
2 |
+
import { App } from "./components/App/App";
|
3 |
+
import { addLogEntry } from "./modules/logEntries";
|
4 |
+
|
5 |
+
createRoot(document.body.appendChild(document.createElement("div"))).render(
|
6 |
+
<App />,
|
7 |
+
);
|
8 |
+
|
9 |
+
addLogEntry("App initialized");
|
client/modules/accessKey.ts
ADDED
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { notifications } from "@mantine/notifications";
|
2 |
+
import { argon2id } from "hash-wasm";
|
3 |
+
import { addLogEntry } from "./logEntries";
|
4 |
+
|
5 |
+
const ACCESS_KEY_STORAGE_KEY = "accessKeyHash";
|
6 |
+
|
7 |
+
interface StoredAccessKey {
|
8 |
+
hash: string;
|
9 |
+
timestamp: number;
|
10 |
+
}
|
11 |
+
|
12 |
+
async function hashAccessKey(accessKey: string): Promise<string> {
|
13 |
+
const salt = new Uint8Array(16);
|
14 |
+
crypto.getRandomValues(salt);
|
15 |
+
|
16 |
+
return argon2id({
|
17 |
+
password: accessKey,
|
18 |
+
salt,
|
19 |
+
parallelism: 1,
|
20 |
+
iterations: 16,
|
21 |
+
memorySize: 512,
|
22 |
+
hashLength: 8,
|
23 |
+
outputType: "encoded",
|
24 |
+
});
|
25 |
+
}
|
26 |
+
|
27 |
+
export async function validateAccessKey(accessKey: string): Promise<boolean> {
|
28 |
+
try {
|
29 |
+
const hash = await hashAccessKey(accessKey);
|
30 |
+
const response = await fetch("/api/validate-access-key", {
|
31 |
+
method: "POST",
|
32 |
+
headers: { "Content-Type": "application/json" },
|
33 |
+
body: JSON.stringify({ accessKeyHash: hash }),
|
34 |
+
});
|
35 |
+
const data = await response.json();
|
36 |
+
|
37 |
+
if (data.valid) {
|
38 |
+
const storedData: StoredAccessKey = {
|
39 |
+
hash,
|
40 |
+
timestamp: Date.now(),
|
41 |
+
};
|
42 |
+
localStorage.setItem(ACCESS_KEY_STORAGE_KEY, JSON.stringify(storedData));
|
43 |
+
addLogEntry("Access key hash stored");
|
44 |
+
}
|
45 |
+
|
46 |
+
return data.valid;
|
47 |
+
} catch (error) {
|
48 |
+
addLogEntry(`Error validating access key: ${error}`);
|
49 |
+
notifications.show({
|
50 |
+
title: "Error validating access key",
|
51 |
+
message: "Please contact the administrator",
|
52 |
+
color: "red",
|
53 |
+
position: "top-right",
|
54 |
+
});
|
55 |
+
return false;
|
56 |
+
}
|
57 |
+
}
|
58 |
+
|
59 |
+
export async function verifyStoredAccessKey(): Promise<boolean> {
|
60 |
+
if (VITE_ACCESS_KEY_TIMEOUT_HOURS === 0) return false;
|
61 |
+
|
62 |
+
const storedData = localStorage.getItem(ACCESS_KEY_STORAGE_KEY);
|
63 |
+
if (!storedData) return false;
|
64 |
+
|
65 |
+
try {
|
66 |
+
const { hash, timestamp }: StoredAccessKey = JSON.parse(storedData);
|
67 |
+
|
68 |
+
const expirationTime = VITE_ACCESS_KEY_TIMEOUT_HOURS * 60 * 60 * 1000;
|
69 |
+
if (Date.now() - timestamp > expirationTime) {
|
70 |
+
localStorage.removeItem(ACCESS_KEY_STORAGE_KEY);
|
71 |
+
addLogEntry("Stored access key expired");
|
72 |
+
return false;
|
73 |
+
}
|
74 |
+
|
75 |
+
const response = await fetch("/api/validate-access-key", {
|
76 |
+
method: "POST",
|
77 |
+
headers: { "Content-Type": "application/json" },
|
78 |
+
body: JSON.stringify({ accessKeyHash: hash }),
|
79 |
+
});
|
80 |
+
|
81 |
+
const data = await response.json();
|
82 |
+
if (!data.valid) {
|
83 |
+
localStorage.removeItem(ACCESS_KEY_STORAGE_KEY);
|
84 |
+
addLogEntry("Stored access key is no longer valid");
|
85 |
+
return false;
|
86 |
+
}
|
87 |
+
|
88 |
+
addLogEntry("Using stored access key");
|
89 |
+
return true;
|
90 |
+
} catch (error) {
|
91 |
+
addLogEntry(`Error verifying stored access key: ${error}`);
|
92 |
+
localStorage.removeItem(ACCESS_KEY_STORAGE_KEY);
|
93 |
+
return false;
|
94 |
+
}
|
95 |
+
}
|
client/modules/logEntries.ts
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createPubSub } from "create-pubsub";
|
2 |
+
|
3 |
+
type LogEntry = {
|
4 |
+
timestamp: string;
|
5 |
+
message: string;
|
6 |
+
};
|
7 |
+
|
8 |
+
export const logEntriesPubSub = createPubSub<LogEntry[]>([]);
|
9 |
+
|
10 |
+
const [updateLogEntries, , getLogEntries] = logEntriesPubSub;
|
11 |
+
|
12 |
+
export function addLogEntry(message: string) {
|
13 |
+
updateLogEntries([
|
14 |
+
...getLogEntries(),
|
15 |
+
{
|
16 |
+
timestamp: new Date().toISOString(),
|
17 |
+
message,
|
18 |
+
},
|
19 |
+
]);
|
20 |
+
}
|
client/modules/openai.ts
ADDED
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import OpenAI, { type ClientOptions } from "openai";
|
2 |
+
|
3 |
+
export function getOpenAiClient({
|
4 |
+
baseURL,
|
5 |
+
apiKey,
|
6 |
+
}: {
|
7 |
+
baseURL: ClientOptions["baseURL"];
|
8 |
+
apiKey: ClientOptions["apiKey"];
|
9 |
+
}) {
|
10 |
+
return new OpenAI({
|
11 |
+
baseURL,
|
12 |
+
apiKey,
|
13 |
+
dangerouslyAllowBrowser: true,
|
14 |
+
defaultHeaders: { "X-Stainless-Retry-Count": null },
|
15 |
+
});
|
16 |
+
}
|
client/modules/parentWindow.ts
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export function postMessageToParentWindow(message: unknown) {
|
2 |
+
const parentWindow = self.parent;
|
3 |
+
const targetOrigin = parentWindow?.[0]?.location?.ancestorOrigins?.[0];
|
4 |
+
if (targetOrigin) parentWindow.postMessage(message, targetOrigin);
|
5 |
+
}
|
client/modules/pubSub.ts
ADDED
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createPubSub } from "create-pubsub";
|
2 |
+
import throttle from "throttleit";
|
3 |
+
import { defaultSettings } from "./settings";
|
4 |
+
|
5 |
+
function createLocalStoragePubSub<T>(localStorageKey: string, defaultValue: T) {
|
6 |
+
const localStorageValue = localStorage.getItem(localStorageKey);
|
7 |
+
const localStoragePubSub = createPubSub(
|
8 |
+
localStorageValue ? (JSON.parse(localStorageValue) as T) : defaultValue,
|
9 |
+
);
|
10 |
+
|
11 |
+
const [, onValueChange] = localStoragePubSub;
|
12 |
+
|
13 |
+
onValueChange((value) =>
|
14 |
+
localStorage.setItem(localStorageKey, JSON.stringify(value)),
|
15 |
+
);
|
16 |
+
|
17 |
+
return localStoragePubSub;
|
18 |
+
}
|
19 |
+
|
20 |
+
const querySuggestionsPubSub = createLocalStoragePubSub<string[]>(
|
21 |
+
"querySuggestions",
|
22 |
+
[],
|
23 |
+
);
|
24 |
+
|
25 |
+
const lastSearchTokenHashPubSub = createLocalStoragePubSub(
|
26 |
+
"lastSearchTokenHash",
|
27 |
+
"",
|
28 |
+
);
|
29 |
+
|
30 |
+
export const [updateLastSearchTokenHash, , getLastSearchTokenHash] =
|
31 |
+
lastSearchTokenHashPubSub;
|
32 |
+
|
33 |
+
export const [updateQuerySuggestions, , getQuerySuggestions] =
|
34 |
+
querySuggestionsPubSub;
|
35 |
+
|
36 |
+
export const queryPubSub = createPubSub(
|
37 |
+
new URLSearchParams(self.location.search).get("q") ?? "",
|
38 |
+
);
|
39 |
+
|
40 |
+
export const [, , getQuery] = queryPubSub;
|
41 |
+
|
42 |
+
export const responsePubSub = createPubSub("");
|
43 |
+
|
44 |
+
export const updateResponse = throttle(responsePubSub[0], 1000 / 12);
|
45 |
+
|
46 |
+
export const searchResultsPubSub = createPubSub<
|
47 |
+
import("./search").SearchResults
|
48 |
+
>({
|
49 |
+
textResults: [],
|
50 |
+
imageResults: [],
|
51 |
+
});
|
52 |
+
|
53 |
+
export const [updateSearchResults, , getSearchResults] = searchResultsPubSub;
|
54 |
+
|
55 |
+
export const [updateSearchPromise, , getSearchPromise] = createPubSub<
|
56 |
+
Promise<import("./search").SearchResults>
|
57 |
+
>(Promise.resolve({ textResults: [], imageResults: [] }));
|
58 |
+
|
59 |
+
export const textGenerationStatePubSub = createPubSub<
|
60 |
+
| "idle"
|
61 |
+
| "awaitingModelDownloadAllowance"
|
62 |
+
| "loadingModel"
|
63 |
+
| "awaitingSearchResults"
|
64 |
+
| "preparingToGenerate"
|
65 |
+
| "generating"
|
66 |
+
| "interrupted"
|
67 |
+
| "failed"
|
68 |
+
| "completed"
|
69 |
+
>("idle");
|
70 |
+
|
71 |
+
export const [updateTextGenerationState, , getTextGenerationState] =
|
72 |
+
textGenerationStatePubSub;
|
73 |
+
|
74 |
+
export const searchStatePubSub = createPubSub<
|
75 |
+
"idle" | "running" | "failed" | "completed"
|
76 |
+
>("idle");
|
77 |
+
|
78 |
+
export const [updateSearchState] = searchStatePubSub;
|
79 |
+
|
80 |
+
export const modelLoadingProgressPubSub = createPubSub(0);
|
81 |
+
|
82 |
+
export const [updateModelLoadingProgress] = modelLoadingProgressPubSub;
|
83 |
+
|
84 |
+
export const settingsPubSub = createLocalStoragePubSub(
|
85 |
+
"settings",
|
86 |
+
defaultSettings,
|
87 |
+
);
|
88 |
+
|
89 |
+
export const [, listenToSettingsChanges, getSettings] = settingsPubSub;
|
90 |
+
|
91 |
+
export const modelSizeInMegabytesPubSub = createPubSub(0);
|
92 |
+
|
93 |
+
export const [updateModelSizeInMegabytes] = modelSizeInMegabytesPubSub;
|
client/modules/querySuggestions.ts
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { addLogEntry } from "./logEntries";
|
2 |
+
import { getQuerySuggestions, updateQuerySuggestions } from "./pubSub";
|
3 |
+
|
4 |
+
export async function getRandomQuerySuggestion() {
|
5 |
+
if (getQuerySuggestions().length === 0) await refillQuerySuggestions(25);
|
6 |
+
|
7 |
+
const querySuggestions = getQuerySuggestions();
|
8 |
+
|
9 |
+
const randomQuerySuggestion = querySuggestions.pop() as string;
|
10 |
+
|
11 |
+
updateQuerySuggestions(querySuggestions);
|
12 |
+
|
13 |
+
return randomQuerySuggestion;
|
14 |
+
}
|
15 |
+
|
16 |
+
async function refillQuerySuggestions(limit?: number) {
|
17 |
+
const querySuggestionsFileUrl = new URL(
|
18 |
+
"/query-suggestions.json",
|
19 |
+
self.location.origin,
|
20 |
+
);
|
21 |
+
|
22 |
+
const fetchResponse = await fetch(querySuggestionsFileUrl.toString());
|
23 |
+
|
24 |
+
const querySuggestionsList: string[] = await fetchResponse.json();
|
25 |
+
|
26 |
+
updateQuerySuggestions(
|
27 |
+
querySuggestionsList.sort(() => Math.random() - 0.5).slice(0, limit),
|
28 |
+
);
|
29 |
+
|
30 |
+
addLogEntry(`Query suggestions refilled with ${limit} suggestions`);
|
31 |
+
}
|
client/modules/search.ts
ADDED
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { name } from "../../package.json";
|
2 |
+
import { addLogEntry } from "./logEntries";
|
3 |
+
import { getSearchTokenHash } from "./searchTokenHash";
|
4 |
+
|
5 |
+
export type SearchResults = {
|
6 |
+
textResults: [title: string, snippet: string, url: string][];
|
7 |
+
imageResults: [
|
8 |
+
title: string,
|
9 |
+
url: string,
|
10 |
+
thumbnailUrl: string,
|
11 |
+
sourceUrl: string,
|
12 |
+
][];
|
13 |
+
};
|
14 |
+
|
15 |
+
/**
|
16 |
+
* Creates a cached version of a search function using IndexedDB for storage.
|
17 |
+
*
|
18 |
+
* @param fn - The original search function to be cached.
|
19 |
+
* @returns A new function that wraps the original, adding caching functionality.
|
20 |
+
*
|
21 |
+
* This function implements a caching mechanism for search results using IndexedDB.
|
22 |
+
* It stores search results with a 15-minute time-to-live (TTL) to improve performance
|
23 |
+
* for repeated searches. The cache is automatically cleaned of expired entries.
|
24 |
+
*
|
25 |
+
* The returned function behaves as follows:
|
26 |
+
* 1. Checks IndexedDB for a cached result matching the query.
|
27 |
+
* 2. If a valid (non-expired) cached result exists, it is returned immediately.
|
28 |
+
* 3. Otherwise, the original search function is called, and its result is both
|
29 |
+
* returned and stored in the cache for future use.
|
30 |
+
*
|
31 |
+
* If IndexedDB is not available, the function falls back to using the original
|
32 |
+
* search function without caching.
|
33 |
+
*/
|
34 |
+
function cacheSearchWithIndexedDB(
|
35 |
+
fn: (query: string, limit?: number) => Promise<SearchResults>,
|
36 |
+
): (query: string, limit?: number) => Promise<SearchResults> {
|
37 |
+
const storeName = "searches";
|
38 |
+
const timeToLive = 15 * 60 * 1000;
|
39 |
+
|
40 |
+
async function openDB(): Promise<IDBDatabase> {
|
41 |
+
return new Promise((resolve, reject) => {
|
42 |
+
const request = indexedDB.open(name, 1);
|
43 |
+
request.onerror = () => reject(request.error);
|
44 |
+
request.onsuccess = () => {
|
45 |
+
const db = request.result;
|
46 |
+
cleanExpiredCache(db);
|
47 |
+
resolve(db);
|
48 |
+
};
|
49 |
+
request.onupgradeneeded = (event) => {
|
50 |
+
const db = (event.target as IDBOpenDBRequest).result;
|
51 |
+
db.createObjectStore(storeName);
|
52 |
+
};
|
53 |
+
});
|
54 |
+
}
|
55 |
+
|
56 |
+
async function cleanExpiredCache(db: IDBDatabase): Promise<void> {
|
57 |
+
const transaction = db.transaction(storeName, "readwrite");
|
58 |
+
const store = transaction.objectStore(storeName);
|
59 |
+
const currentTime = Date.now();
|
60 |
+
|
61 |
+
return new Promise((resolve) => {
|
62 |
+
const request = store.openCursor();
|
63 |
+
request.onsuccess = (event) => {
|
64 |
+
const cursor = (event.target as IDBRequest).result;
|
65 |
+
if (cursor) {
|
66 |
+
if (currentTime - cursor.value.timestamp >= timeToLive) {
|
67 |
+
cursor.delete();
|
68 |
+
}
|
69 |
+
cursor.continue();
|
70 |
+
} else {
|
71 |
+
resolve();
|
72 |
+
}
|
73 |
+
};
|
74 |
+
});
|
75 |
+
}
|
76 |
+
|
77 |
+
/**
|
78 |
+
* Generates a hash for a given query string.
|
79 |
+
*
|
80 |
+
* This function implements a simple hash algorithm:
|
81 |
+
* 1. It iterates through each character in the query string.
|
82 |
+
* 2. For each character, it updates the hash value using bitwise operations.
|
83 |
+
* 3. The final hash is converted to a 32-bit integer.
|
84 |
+
* 4. The result is returned as a base-36 string representation.
|
85 |
+
*
|
86 |
+
* @param query - The input string to be hashed.
|
87 |
+
* @returns A string representation of the hash in base-36.
|
88 |
+
*/
|
89 |
+
function hashQuery(query: string): string {
|
90 |
+
return query
|
91 |
+
.split("")
|
92 |
+
.reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) | 0, 0)
|
93 |
+
.toString(36);
|
94 |
+
}
|
95 |
+
|
96 |
+
const dbPromise = openDB();
|
97 |
+
|
98 |
+
return async (query: string, limit?: number): Promise<SearchResults> => {
|
99 |
+
addLogEntry("Starting new search");
|
100 |
+
if (!indexedDB) return fn(query, limit);
|
101 |
+
|
102 |
+
const db = await dbPromise;
|
103 |
+
const transaction = db.transaction(storeName, "readwrite");
|
104 |
+
const store = transaction.objectStore(storeName);
|
105 |
+
const key = hashQuery(query);
|
106 |
+
const cachedResult = await new Promise<
|
107 |
+
| {
|
108 |
+
results: SearchResults;
|
109 |
+
timestamp: number;
|
110 |
+
}
|
111 |
+
| undefined
|
112 |
+
>((resolve) => {
|
113 |
+
const request = store.get(key);
|
114 |
+
request.onerror = () => resolve(undefined);
|
115 |
+
request.onsuccess = () => resolve(request.result);
|
116 |
+
});
|
117 |
+
|
118 |
+
if (cachedResult && Date.now() - cachedResult.timestamp < timeToLive) {
|
119 |
+
addLogEntry(
|
120 |
+
`Search cache hit, returning cached results containing ${cachedResult.results.textResults.length} texts and ${cachedResult.results.imageResults.length} images`,
|
121 |
+
);
|
122 |
+
return cachedResult.results;
|
123 |
+
}
|
124 |
+
|
125 |
+
addLogEntry("Search cache miss, fetching new results");
|
126 |
+
|
127 |
+
const results = await fn(query, limit);
|
128 |
+
|
129 |
+
const writeTransaction = db.transaction(storeName, "readwrite");
|
130 |
+
const writeStore = writeTransaction.objectStore(storeName);
|
131 |
+
writeStore.put({ results, timestamp: Date.now() }, key);
|
132 |
+
|
133 |
+
addLogEntry(
|
134 |
+
`Search completed with ${results.textResults.length} text results and ${results.imageResults.length} image results`,
|
135 |
+
);
|
136 |
+
|
137 |
+
return results;
|
138 |
+
};
|
139 |
+
}
|
140 |
+
|
141 |
+
export const search = cacheSearchWithIndexedDB(
|
142 |
+
async (query: string, limit?: number): Promise<SearchResults> => {
|
143 |
+
const searchUrl = new URL("/search", self.location.origin);
|
144 |
+
|
145 |
+
searchUrl.searchParams.set("q", query);
|
146 |
+
|
147 |
+
searchUrl.searchParams.set("token", await getSearchTokenHash());
|
148 |
+
|
149 |
+
if (limit && limit > 0) {
|
150 |
+
searchUrl.searchParams.set("limit", limit.toString());
|
151 |
+
}
|
152 |
+
|
153 |
+
const response = await fetch(searchUrl.toString());
|
154 |
+
|
155 |
+
return response.ok
|
156 |
+
? response.json()
|
157 |
+
: { textResults: [], imageResults: [] };
|
158 |
+
},
|
159 |
+
);
|
client/modules/searchTokenHash.ts
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { argon2Verify, argon2id } from "hash-wasm";
|
2 |
+
import { addLogEntry } from "./logEntries";
|
3 |
+
import { getLastSearchTokenHash, updateLastSearchTokenHash } from "./pubSub";
|
4 |
+
|
5 |
+
export async function getSearchTokenHash() {
|
6 |
+
const password = VITE_SEARCH_TOKEN;
|
7 |
+
const lastSearchTokenHash = getLastSearchTokenHash();
|
8 |
+
|
9 |
+
try {
|
10 |
+
const lastSearchTokenHashIsValid = await argon2Verify({
|
11 |
+
password,
|
12 |
+
hash: lastSearchTokenHash,
|
13 |
+
});
|
14 |
+
|
15 |
+
if (lastSearchTokenHashIsValid) {
|
16 |
+
addLogEntry("Using cached search token hash");
|
17 |
+
return lastSearchTokenHash;
|
18 |
+
}
|
19 |
+
} catch (error) {
|
20 |
+
void error;
|
21 |
+
}
|
22 |
+
|
23 |
+
const salt = new Uint8Array(16);
|
24 |
+
crypto.getRandomValues(salt);
|
25 |
+
|
26 |
+
const newSearchTokenHash = await argon2id({
|
27 |
+
password,
|
28 |
+
salt,
|
29 |
+
parallelism: 1,
|
30 |
+
iterations: 16,
|
31 |
+
memorySize: 512,
|
32 |
+
hashLength: 8,
|
33 |
+
outputType: "encoded",
|
34 |
+
});
|
35 |
+
|
36 |
+
updateLastSearchTokenHash(newSearchTokenHash);
|
37 |
+
|
38 |
+
addLogEntry("New search token hash generated");
|
39 |
+
|
40 |
+
return newSearchTokenHash;
|
41 |
+
}
|
client/modules/settings.ts
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { addLogEntry } from "./logEntries";
|
2 |
+
import { isF16Supported } from "./webGpu";
|
3 |
+
|
4 |
+
export const defaultSettings = {
|
5 |
+
enableAiResponse: true,
|
6 |
+
enableWebGpu: true,
|
7 |
+
enableImageSearch: true,
|
8 |
+
webLlmModelId: isF16Supported
|
9 |
+
? VITE_WEBLLM_DEFAULT_F16_MODEL_ID
|
10 |
+
: VITE_WEBLLM_DEFAULT_F32_MODEL_ID,
|
11 |
+
wllamaModelId: VITE_WLLAMA_DEFAULT_MODEL_ID,
|
12 |
+
cpuThreads: 1,
|
13 |
+
searchResultsToConsider: 3,
|
14 |
+
systemPrompt: `I need assistance with my research, so please provide easy-to-understand responses following these guidelines:
|
15 |
+
- Base your responses on the provided search results and your general knowledge about the topic.
|
16 |
+
- Answer in the same language in which I ask, with an analytical tone.
|
17 |
+
- Use Markdown format, without headers.
|
18 |
+
- Include any additional relevant information you think would be good to know.
|
19 |
+
- Keep in mind that the current date and time is {{dateTime}}.
|
20 |
+
|
21 |
+
Search results:
|
22 |
+
{{searchResults}}`,
|
23 |
+
inferenceType: VITE_DEFAULT_INFERENCE_TYPE,
|
24 |
+
inferenceTemperature: 0.5,
|
25 |
+
inferenceTopP: 1.0,
|
26 |
+
inferenceFrequencyPenalty: 0.5,
|
27 |
+
inferencePresencePenalty: 0.3,
|
28 |
+
inferenceRepeatPenalty: 1.176,
|
29 |
+
openAiApiBaseUrl: "",
|
30 |
+
openAiApiKey: "",
|
31 |
+
openAiApiModel: "",
|
32 |
+
enterToSubmit: true,
|
33 |
+
enableAiResponseScrolling: true,
|
34 |
+
allowAiModelDownload: false,
|
35 |
+
};
|
36 |
+
|
37 |
+
addLogEntry(
|
38 |
+
`Number of logical processors in CPU: ${navigator.hardwareConcurrency ?? "unknown"}`,
|
39 |
+
);
|
40 |
+
|
41 |
+
export type Settings = typeof defaultSettings;
|
42 |
+
|
43 |
+
export const inferenceTypes = [
|
44 |
+
{ value: "browser", label: "In the browser (Private)" },
|
45 |
+
{ value: "openai", label: "Remote server (API)" },
|
46 |
+
...(VITE_INTERNAL_API_ENABLED
|
47 |
+
? [{ value: "internal", label: VITE_INTERNAL_API_NAME }]
|
48 |
+
: []),
|
49 |
+
];
|