Spaces:
Running
Running
github-actions[bot]
commited on
Commit
β’
49ee91e
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 +33 -0
- .github/workflows/deploy.yml +54 -0
- .github/workflows/llama-cpp.yml +175 -0
- .github/workflows/on-pull-request-to-main.yml +9 -0
- .github/workflows/on-push-to-main.yml +7 -0
- .github/workflows/reusable-test-lint-ping.yml +25 -0
- .github/workflows/update-searxng-docker-image.yml +44 -0
- .gitignore +7 -0
- .npmrc +1 -0
- Dockerfile +82 -0
- README.md +139 -0
- biome.json +30 -0
- client/components/AiResponse/AiModelDownloadAllowanceContent.tsx +62 -0
- client/components/AiResponse/AiResponseContent.tsx +199 -0
- client/components/AiResponse/AiResponseSection.tsx +105 -0
- client/components/AiResponse/ChatInterface.tsx +186 -0
- client/components/AiResponse/CopyIconButton.tsx +32 -0
- client/components/AiResponse/FormattedMarkdown.tsx +107 -0
- client/components/AiResponse/LoadingModelContent.tsx +40 -0
- client/components/AiResponse/PreparingContent.tsx +33 -0
- client/components/AiResponse/WebLlmModelSelect.tsx +81 -0
- client/components/AiResponse/WllamaModelSelect.tsx +42 -0
- client/components/App/App.tsx +94 -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 +60 -0
- client/components/Pages/Main/Menu/AISettingsForm.tsx +441 -0
- client/components/Pages/Main/Menu/ActionsForm.tsx +18 -0
- client/components/Pages/Main/Menu/ClearDataButton.tsx +63 -0
- client/components/Pages/Main/Menu/InterfaceSettingsForm.tsx +45 -0
- client/components/Pages/Main/Menu/MenuButton.tsx +53 -0
- client/components/Pages/Main/Menu/MenuDrawer.tsx +120 -0
- client/components/Pages/Main/Menu/SearchSettingsForm.tsx +52 -0
- client/components/Pages/Main/Menu/VoiceSettingsForm.tsx +72 -0
- client/components/Search/Form/SearchForm.tsx +131 -0
- client/components/Search/Results/Graphical/ImageResultsList.tsx +122 -0
- client/components/Search/Results/Graphical/ImageResultsLoadingState.tsx +22 -0
- client/components/Search/Results/Graphical/ImageSearchResults.tsx +62 -0
- client/components/Search/Results/SearchResultsSection.tsx +49 -0
- client/components/Search/Results/Textual/SearchResultsList.tsx +87 -0
- client/components/Search/Results/Textual/TextResultsLoadingState.tsx +18 -0
- client/components/Search/Results/Textual/TextSearchResults.tsx +60 -0
- client/index.html +36 -0
- client/index.tsx +9 -0
- client/modules/accessKey.ts +95 -0
- client/modules/keyboard.ts +15 -0
- client/modules/logEntries.ts +20 -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,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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="Qwen2.5-0.5B-Instruct-q4f16_1-MLC"
|
9 |
+
|
10 |
+
# The default model ID for WebLLM with F32 shaders.
|
11 |
+
WEBLLM_DEFAULT_F32_MODEL_ID="Qwen2.5-0.5B-Instruct-q4f32_1-MLC"
|
12 |
+
|
13 |
+
# The default model ID for Wllama.
|
14 |
+
WLLAMA_DEFAULT_MODEL_ID="qwen-2.5-0.5b"
|
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 |
+
# "horde" -> AI Horde (Pre-configured)
|
32 |
+
# "internal" -> $INTERNAL_OPENAI_COMPATIBLE_API_NAME
|
33 |
+
DEFAULT_INFERENCE_TYPE="browser"
|
.github/workflows/deploy.yml
ADDED
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
name: Deploy
|
2 |
+
|
3 |
+
on:
|
4 |
+
workflow_dispatch:
|
5 |
+
|
6 |
+
jobs:
|
7 |
+
build-and-push-image:
|
8 |
+
name: Publish Docker image to GitHub Packages
|
9 |
+
runs-on: ubuntu-latest
|
10 |
+
env:
|
11 |
+
REGISTRY: ghcr.io
|
12 |
+
IMAGE_NAME: ${{ github.repository }}
|
13 |
+
permissions:
|
14 |
+
contents: read
|
15 |
+
packages: write
|
16 |
+
steps:
|
17 |
+
- name: Checkout repository
|
18 |
+
uses: actions/checkout@v4
|
19 |
+
- name: Log in to the Container registry
|
20 |
+
uses: docker/login-action@v3
|
21 |
+
with:
|
22 |
+
registry: ${{ env.REGISTRY }}
|
23 |
+
username: ${{ github.actor }}
|
24 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
25 |
+
- name: Extract metadata (tags, labels) for Docker
|
26 |
+
id: meta
|
27 |
+
uses: docker/metadata-action@v5
|
28 |
+
with:
|
29 |
+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
30 |
+
- name: Set up Docker Buildx
|
31 |
+
uses: docker/setup-buildx-action@v3
|
32 |
+
- name: Build and push Docker image
|
33 |
+
uses: docker/build-push-action@v6
|
34 |
+
with:
|
35 |
+
context: .
|
36 |
+
push: true
|
37 |
+
tags: ${{ steps.meta.outputs.tags }}
|
38 |
+
labels: ${{ steps.meta.outputs.labels }}
|
39 |
+
platforms: linux/amd64,linux/arm64
|
40 |
+
|
41 |
+
sync-to-hf:
|
42 |
+
name: Sync to HuggingFace Spaces
|
43 |
+
runs-on: ubuntu-latest
|
44 |
+
steps:
|
45 |
+
- uses: actions/checkout@v4
|
46 |
+
with:
|
47 |
+
lfs: true
|
48 |
+
- uses: JacobLinCool/huggingface-sync@v1
|
49 |
+
with:
|
50 |
+
github: ${{ secrets.GITHUB_TOKEN }}
|
51 |
+
user: ${{ vars.HF_SPACE_OWNER }}
|
52 |
+
space: ${{ vars.HF_SPACE_NAME }}
|
53 |
+
token: ${{ secrets.HF_TOKEN }}
|
54 |
+
configuration: "hf-space-config.yml"
|
.github/workflows/llama-cpp.yml
ADDED
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
name: Review Pull Request with llama.cpp
|
2 |
+
|
3 |
+
on:
|
4 |
+
pull_request:
|
5 |
+
types: [opened, synchronize, reopened]
|
6 |
+
branches: ["main"]
|
7 |
+
|
8 |
+
concurrency:
|
9 |
+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
10 |
+
cancel-in-progress: true
|
11 |
+
|
12 |
+
jobs:
|
13 |
+
llama-cpp:
|
14 |
+
if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-ai-review') }}
|
15 |
+
continue-on-error: true
|
16 |
+
runs-on: ubuntu-latest
|
17 |
+
name: llama.cpp
|
18 |
+
permissions:
|
19 |
+
pull-requests: write
|
20 |
+
contents: read
|
21 |
+
timeout-minutes: 120
|
22 |
+
env:
|
23 |
+
HF_MODEL_NAME: Qwen2.5.1-Coder-7B-Instruct-GGUF
|
24 |
+
steps:
|
25 |
+
- name: Checkout Repository
|
26 |
+
uses: actions/checkout@v4
|
27 |
+
|
28 |
+
- name: Create temporary directory
|
29 |
+
run: mkdir -p /tmp/llama_review
|
30 |
+
|
31 |
+
- name: Process PR description
|
32 |
+
id: process_pr
|
33 |
+
run: |
|
34 |
+
PR_BODY_ESCAPED=$(cat << 'EOF'
|
35 |
+
${{ github.event.pull_request.body }}
|
36 |
+
EOF
|
37 |
+
)
|
38 |
+
PROCESSED_BODY=$(echo "$PR_BODY_ESCAPED" | sed -E 's/\[(.*?)\]\(.*?\)/\1/g')
|
39 |
+
echo "$PROCESSED_BODY" > /tmp/llama_review/processed_body.txt
|
40 |
+
|
41 |
+
- name: Fetch branches and output the diff in this step
|
42 |
+
run: |
|
43 |
+
git fetch origin main:main
|
44 |
+
git fetch origin pull/${{ github.event.pull_request.number }}/head:pr-branch
|
45 |
+
git diff main..pr-branch > /tmp/llama_review/diff.txt
|
46 |
+
|
47 |
+
- name: Write prompt to file
|
48 |
+
id: build_prompt
|
49 |
+
run: |
|
50 |
+
PR_TITLE=$(echo "${{ github.event.pull_request.title }}" | sed 's/[()]/\\&/g')
|
51 |
+
DIFF_CONTENT=$(cat /tmp/llama_review/diff.txt)
|
52 |
+
PROCESSED_BODY=$(cat /tmp/llama_review/processed_body.txt)
|
53 |
+
echo "<|im_start|>system
|
54 |
+
You are an experienced developer reviewing a Pull Request. You focus only on what matters and provide concise, actionable feedback.
|
55 |
+
|
56 |
+
Review Context:
|
57 |
+
Repository Name: \"${{ github.event.repository.name }}\"
|
58 |
+
Repository Description: \"${{ github.event.repository.description }}\"
|
59 |
+
Branch: \"${{ github.event.pull_request.head.ref }}\"
|
60 |
+
PR Title: \"$PR_TITLE\"
|
61 |
+
|
62 |
+
Guidelines:
|
63 |
+
1. Only comment on issues that:
|
64 |
+
- Could cause bugs or security issues
|
65 |
+
- Significantly impact performance
|
66 |
+
- Make the code harder to maintain
|
67 |
+
- Violate critical best practices
|
68 |
+
|
69 |
+
2. For each issue:
|
70 |
+
- Point to the specific line/file
|
71 |
+
- Explain why it's a problem
|
72 |
+
- Suggest a concrete fix
|
73 |
+
|
74 |
+
3. Praise exceptional solutions briefly, only if truly innovative
|
75 |
+
|
76 |
+
4. Skip commenting on:
|
77 |
+
- Minor style issues
|
78 |
+
- Obvious changes
|
79 |
+
- Working code that could be marginally improved
|
80 |
+
- Things that are just personal preference
|
81 |
+
|
82 |
+
Remember:
|
83 |
+
Less is more. If the code is good and working, just say so, with a short message.<|im_end|>
|
84 |
+
<|im_start|>user
|
85 |
+
This is the description of the pull request:
|
86 |
+
\`\`\`markdown
|
87 |
+
$PROCESSED_BODY
|
88 |
+
\`\`\`
|
89 |
+
|
90 |
+
And here is the diff of the changes, for you to review:
|
91 |
+
\`\`\`diff
|
92 |
+
$DIFF_CONTENT
|
93 |
+
\`\`\`
|
94 |
+
<|im_end|>
|
95 |
+
<|im_start|>assistant
|
96 |
+
### Overall Summary
|
97 |
+
" > /tmp/llama_review/prompt.txt
|
98 |
+
|
99 |
+
- name: Show Prompt
|
100 |
+
run: cat /tmp/llama_review/prompt.txt
|
101 |
+
|
102 |
+
- name: Set up Homebrew
|
103 |
+
uses: Homebrew/actions/setup-homebrew@master
|
104 |
+
|
105 |
+
- name: Install and cache Homebrew tools
|
106 |
+
uses: tecolicom/actions-use-homebrew-tools@v1
|
107 |
+
with:
|
108 |
+
tools: llama.cpp
|
109 |
+
|
110 |
+
- name: Cache the LLM
|
111 |
+
id: cache_llama_cpp
|
112 |
+
uses: actions/cache@v4
|
113 |
+
with:
|
114 |
+
path: ~/.cache/llama.cpp/
|
115 |
+
key: llama-cpp-${{ env.HF_MODEL_NAME }}
|
116 |
+
|
117 |
+
- name: Download and cache the LLM
|
118 |
+
if: steps.cache_llama_cpp.outputs.cache-hit != 'true'
|
119 |
+
run: |
|
120 |
+
mkdir -p ~/.cache/llama.cpp/
|
121 |
+
curl -L -o ~/.cache/llama.cpp/model.gguf https://huggingface.co/bartowski/${{ env.HF_MODEL_NAME }}/resolve/main/Qwen2.5.1-Coder-7B-Instruct-IQ4_XS.gguf
|
122 |
+
|
123 |
+
- name: Run llama.cpp
|
124 |
+
run: |
|
125 |
+
llama-server \
|
126 |
+
--model ~/.cache/llama.cpp/model.gguf \
|
127 |
+
--ctx-size 32768 \
|
128 |
+
--threads -1 \
|
129 |
+
--predict -1 \
|
130 |
+
--temp 0.5 \
|
131 |
+
--top-p 0.9 \
|
132 |
+
--min-p 0.1 \
|
133 |
+
--top-k 0 \
|
134 |
+
--cache-type-k q8_0 \
|
135 |
+
--cache-type-v q8_0 \
|
136 |
+
--flash-attn \
|
137 |
+
--port 11434 &
|
138 |
+
|
139 |
+
- name: cURL llama-server to get the completion and timings
|
140 |
+
run: |
|
141 |
+
DATA=$(jq -n --arg prompt "$(cat /tmp/llama_review/prompt.txt)" '{"prompt": $prompt}')
|
142 |
+
echo -e '### Review\n\n' > /tmp/llama_review/response.txt
|
143 |
+
# Save the full response to a temporary file
|
144 |
+
curl \
|
145 |
+
--silent \
|
146 |
+
--request POST \
|
147 |
+
--url http://localhost:11434/completion \
|
148 |
+
--header "Content-Type: application/json" \
|
149 |
+
--data "$DATA" > /tmp/llama_review/full_response.json
|
150 |
+
|
151 |
+
# Extract and append content to response.txt
|
152 |
+
jq -r '.content' /tmp/llama_review/full_response.json >> /tmp/llama_review/response.txt
|
153 |
+
|
154 |
+
# Pretty print the timings information
|
155 |
+
echo "=== Performance Metrics ==="
|
156 |
+
jq -r '.timings | to_entries | .[] | "\(.key): \(.value)"' /tmp/llama_review/full_response.json
|
157 |
+
|
158 |
+
- name: Show Response
|
159 |
+
run: cat /tmp/llama_review/response.txt
|
160 |
+
|
161 |
+
- name: Find Comment
|
162 |
+
uses: peter-evans/find-comment@v3
|
163 |
+
id: find_comment
|
164 |
+
with:
|
165 |
+
issue-number: ${{ github.event.pull_request.number }}
|
166 |
+
comment-author: "github-actions[bot]"
|
167 |
+
body-includes: "### Review"
|
168 |
+
|
169 |
+
- name: Post or Update PR Review
|
170 |
+
uses: peter-evans/create-or-update-comment@v4
|
171 |
+
with:
|
172 |
+
comment-id: ${{ steps.find_comment.outputs.comment-id }}
|
173 |
+
issue-number: ${{ github.event.pull_request.number }}
|
174 |
+
body-path: /tmp/llama_review/response.txt
|
175 |
+
edit-mode: replace
|
.github/workflows/on-pull-request-to-main.yml
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-test-lint-ping') }}
|
9 |
+
uses: ./.github/workflows/reusable-test-lint-ping.yml
|
.github/workflows/on-push-to-main.yml
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
name: On Push To Main
|
2 |
+
on:
|
3 |
+
push:
|
4 |
+
branches: ["main"]
|
5 |
+
jobs:
|
6 |
+
test-lint-ping:
|
7 |
+
uses: ./.github/workflows/reusable-test-lint-ping.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,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
node_modules
|
2 |
+
.DS_Store
|
3 |
+
/client/dist
|
4 |
+
/server/models
|
5 |
+
.vscode
|
6 |
+
/vite-build-stats.html
|
7 |
+
.env
|
.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.12.16-65c970bdf
|
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,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: true
|
9 |
+
custom_headers:
|
10 |
+
cross-origin-embedder-policy: require-corp
|
11 |
+
cross-origin-opener-policy: same-origin
|
12 |
+
cross-origin-resource-policy: cross-origin
|
13 |
+
---
|
14 |
+
|
15 |
+
# MiniSearch
|
16 |
+
|
17 |
+
A minimalist web-searching app with an AI assistant that runs directly from your browser.
|
18 |
+
|
19 |
+
Live demo: https://felladrin-minisearch.hf.space
|
20 |
+
|
21 |
+
## Screenshot
|
22 |
+
|
23 |
+
![MiniSearch Screenshot](https://github.com/user-attachments/assets/f8d72a8e-a725-42e9-9358-e6ebade2acb2)
|
24 |
+
|
25 |
+
## Features
|
26 |
+
|
27 |
+
- **Privacy-focused**: [No tracking, no ads, no data collection](https://docs.searxng.org/own-instance.html#how-does-searxng-protect-privacy)
|
28 |
+
- **Easy to use**: Minimalist yet intuitive interface for all users
|
29 |
+
- **Cross-platform**: Models run inside the browser, both on desktop and mobile
|
30 |
+
- **Integrated**: Search from the browser address bar by setting it as the default search engine
|
31 |
+
- **Efficient**: Models are loaded and cached only when needed
|
32 |
+
- **Customizable**: Tweakable settings for search results and text generation
|
33 |
+
- **Open-source**: [The code is available for inspection and contribution at GitHub](https://github.com/felladrin/MiniSearch)
|
34 |
+
|
35 |
+
## Prerequisites
|
36 |
+
|
37 |
+
- [Docker](https://docs.docker.com/get-docker/)
|
38 |
+
|
39 |
+
## Getting started
|
40 |
+
|
41 |
+
Here are the easiest ways to get started with MiniSearch. Pick the one that suits you best.
|
42 |
+
|
43 |
+
**Option 1** - Use [MiniSearch's Docker Image](https://github.com/felladrin/MiniSearch/pkgs/container/minisearch) by running in your terminal:
|
44 |
+
|
45 |
+
```bash
|
46 |
+
docker run -p 7860:7860 ghcr.io/felladrin/minisearch:main
|
47 |
+
```
|
48 |
+
|
49 |
+
**Option 2** - Add MiniSearch's Docker Image to your existing Docker Compose file:
|
50 |
+
|
51 |
+
```yaml
|
52 |
+
services:
|
53 |
+
minisearch:
|
54 |
+
image: ghcr.io/felladrin/minisearch:main
|
55 |
+
ports:
|
56 |
+
- "7860:7860"
|
57 |
+
```
|
58 |
+
|
59 |
+
**Option 3** - Build from source by [downloading the repository files](https://github.com/felladrin/MiniSearch/archive/refs/heads/main.zip) and running:
|
60 |
+
|
61 |
+
```bash
|
62 |
+
docker compose -f docker-compose.production.yml up --build
|
63 |
+
```
|
64 |
+
|
65 |
+
Once the container is running, open http://localhost:7860 in your browser and start searching!
|
66 |
+
|
67 |
+
## Frequently asked questions
|
68 |
+
|
69 |
+
<details>
|
70 |
+
<summary>How do I search via the browser's address bar?</summary>
|
71 |
+
<p>
|
72 |
+
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>.
|
73 |
+
</p>
|
74 |
+
</details>
|
75 |
+
|
76 |
+
<details>
|
77 |
+
<summary>How do I search via Raycast?</summary>
|
78 |
+
<p>
|
79 |
+
You can add <a href="https://ray.so/quicklinks/shared?quicklinks=%7B%22link%22:%22https:%5C/%5C/felladrin-minisearch.hf.space%5C/?q%3D%7BQuery%7D%22,%22name%22:%22MiniSearch%22%7D" target="_blank">this Quicklink</a> to Raycast, so typying your query will open MiniSearch with the search results. You can also edit it to point to your own domain.
|
80 |
+
</p>
|
81 |
+
<img width="744" alt="image" src="https://github.com/user-attachments/assets/521dca22-c77b-42de-8cc8-9feb06f9a97e">
|
82 |
+
</details>
|
83 |
+
|
84 |
+
<details>
|
85 |
+
<summary>Can I use custom models via OpenAI-Compatible API?</summary>
|
86 |
+
<p>
|
87 |
+
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.
|
88 |
+
</p>
|
89 |
+
</details>
|
90 |
+
|
91 |
+
<details>
|
92 |
+
<summary>How do I restrict the access to my MiniSearch instance via password?</summary>
|
93 |
+
<p>
|
94 |
+
Create a <code>.env</code> file and set a value for <code>ACCESS_KEYS</code>. Then reset the MiniSearch docker container.
|
95 |
+
</p>
|
96 |
+
<p>
|
97 |
+
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/>
|
98 |
+
<code>ACCESS_KEYS="PepperoniPizza"</code>
|
99 |
+
</p>
|
100 |
+
<p>
|
101 |
+
You can find more examples in the <code>.env.example</code> file.
|
102 |
+
</p>
|
103 |
+
</details>
|
104 |
+
|
105 |
+
<details>
|
106 |
+
<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>
|
107 |
+
<p>Yes! In MiniSearch, we call this text-generation feature "Internal OpenAI-Compatible API". To use this it:</p>
|
108 |
+
<ol>
|
109 |
+
<li>Set up your OpenAI-Compatible API endpoint by configuring the following environment variables in your <code>.env</code> file:
|
110 |
+
<ul>
|
111 |
+
<li><code>INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL</code>: The base URL for your API</li>
|
112 |
+
<li><code>INTERNAL_OPENAI_COMPATIBLE_API_KEY</code>: Your API access key</li>
|
113 |
+
<li><code>INTERNAL_OPENAI_COMPATIBLE_API_MODEL</code>: The model to use</li>
|
114 |
+
<li><code>INTERNAL_OPENAI_COMPATIBLE_API_NAME</code>: The name to display in the UI</li>
|
115 |
+
</ul>
|
116 |
+
</li>
|
117 |
+
<li>Restart MiniSearch server.</li>
|
118 |
+
<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>
|
119 |
+
</ol>
|
120 |
+
</details>
|
121 |
+
|
122 |
+
<details>
|
123 |
+
<summary>How can I contribute to the development of this tool?</summary>
|
124 |
+
<p>Fork this repository and clone it. Then, start the development server by running the following command:</p>
|
125 |
+
<p><code>docker compose up</code></p>
|
126 |
+
<p>Make your changes, push them to your fork, and open a pull request! All contributions are welcome!</p>
|
127 |
+
</details>
|
128 |
+
|
129 |
+
<details>
|
130 |
+
<summary>Why is MiniSearch built upon SearXNG's Docker Image and using a single image instead of composing it from multiple services?</summary>
|
131 |
+
<p>There are a few reasons for this:</p>
|
132 |
+
<ul>
|
133 |
+
<li>MiniSearch utilizes SearXNG as its meta-search engine.</li>
|
134 |
+
<li>Manual installation of SearXNG is not trivial, so we use the docker image they provide, which has everything set up.</li>
|
135 |
+
<li>SearXNG only provides a Docker Image based on Alpine Linux.</li>
|
136 |
+
<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>
|
137 |
+
<li>HuggingFace only accepts a single docker image. It doesn't run docker compose or multiple images, unfortunately.</li>
|
138 |
+
</ul>
|
139 |
+
</details>
|
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,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
ActionIcon,
|
3 |
+
Alert,
|
4 |
+
Badge,
|
5 |
+
Box,
|
6 |
+
Card,
|
7 |
+
Group,
|
8 |
+
ScrollArea,
|
9 |
+
Text,
|
10 |
+
Tooltip,
|
11 |
+
} from "@mantine/core";
|
12 |
+
import {
|
13 |
+
IconArrowsMaximize,
|
14 |
+
IconArrowsMinimize,
|
15 |
+
IconHandStop,
|
16 |
+
IconInfoCircle,
|
17 |
+
IconRefresh,
|
18 |
+
IconVolume2,
|
19 |
+
} from "@tabler/icons-react";
|
20 |
+
import type { PublishFunction } from "create-pubsub";
|
21 |
+
import { usePubSub } from "create-pubsub/react";
|
22 |
+
import { type ReactNode, Suspense, lazy, useMemo, useState } from "react";
|
23 |
+
import { settingsPubSub } from "../../modules/pubSub";
|
24 |
+
import { searchAndRespond } from "../../modules/textGeneration";
|
25 |
+
|
26 |
+
const FormattedMarkdown = lazy(() => import("./FormattedMarkdown"));
|
27 |
+
const CopyIconButton = lazy(() => import("./CopyIconButton"));
|
28 |
+
|
29 |
+
export default function AiResponseContent({
|
30 |
+
textGenerationState,
|
31 |
+
response,
|
32 |
+
setTextGenerationState,
|
33 |
+
}: {
|
34 |
+
textGenerationState: string;
|
35 |
+
response: string;
|
36 |
+
setTextGenerationState: PublishFunction<
|
37 |
+
| "failed"
|
38 |
+
| "awaitingSearchResults"
|
39 |
+
| "preparingToGenerate"
|
40 |
+
| "idle"
|
41 |
+
| "loadingModel"
|
42 |
+
| "generating"
|
43 |
+
| "interrupted"
|
44 |
+
| "completed"
|
45 |
+
>;
|
46 |
+
}) {
|
47 |
+
const [settings, setSettings] = usePubSub(settingsPubSub);
|
48 |
+
const [isSpeaking, setIsSpeaking] = useState(false);
|
49 |
+
|
50 |
+
const ConditionalScrollArea = useMemo(
|
51 |
+
() =>
|
52 |
+
({ children }: { children: ReactNode }) => {
|
53 |
+
return settings.enableAiResponseScrolling ? (
|
54 |
+
<ScrollArea.Autosize mah={300} type="auto" offsetScrollbars>
|
55 |
+
{children}
|
56 |
+
</ScrollArea.Autosize>
|
57 |
+
) : (
|
58 |
+
<Box>{children}</Box>
|
59 |
+
);
|
60 |
+
},
|
61 |
+
[settings.enableAiResponseScrolling],
|
62 |
+
);
|
63 |
+
|
64 |
+
function speakResponse(text: string) {
|
65 |
+
if (isSpeaking) {
|
66 |
+
self.speechSynthesis.cancel();
|
67 |
+
setIsSpeaking(false);
|
68 |
+
return;
|
69 |
+
}
|
70 |
+
|
71 |
+
const cleanText = text.replace(/[#*`_~\[\]]/g, "");
|
72 |
+
const utterance = new SpeechSynthesisUtterance(cleanText);
|
73 |
+
|
74 |
+
const voices = self.speechSynthesis.getVoices();
|
75 |
+
|
76 |
+
if (voices.length > 0 && settings.selectedVoiceId) {
|
77 |
+
const voice = voices.find(
|
78 |
+
(voice) => voice.voiceURI === settings.selectedVoiceId,
|
79 |
+
);
|
80 |
+
|
81 |
+
if (voice) {
|
82 |
+
utterance.voice = voice;
|
83 |
+
utterance.lang = voice.lang;
|
84 |
+
}
|
85 |
+
}
|
86 |
+
|
87 |
+
utterance.onend = () => setIsSpeaking(false);
|
88 |
+
|
89 |
+
setIsSpeaking(true);
|
90 |
+
self.speechSynthesis.speak(utterance);
|
91 |
+
}
|
92 |
+
|
93 |
+
return (
|
94 |
+
<Card withBorder shadow="sm" radius="md">
|
95 |
+
<Card.Section withBorder inheritPadding py="xs">
|
96 |
+
<Group justify="space-between">
|
97 |
+
<Group gap="xs" align="center">
|
98 |
+
<Text fw={500}>
|
99 |
+
{textGenerationState === "generating"
|
100 |
+
? "Generating AI Response..."
|
101 |
+
: "AI Response"}
|
102 |
+
</Text>
|
103 |
+
{textGenerationState === "interrupted" && (
|
104 |
+
<Badge variant="light" color="yellow" size="xs">
|
105 |
+
Interrupted
|
106 |
+
</Badge>
|
107 |
+
)}
|
108 |
+
</Group>
|
109 |
+
<Group gap="xs" align="center">
|
110 |
+
{textGenerationState === "generating" ? (
|
111 |
+
<Tooltip label="Interrupt generation">
|
112 |
+
<ActionIcon
|
113 |
+
onClick={() => setTextGenerationState("interrupted")}
|
114 |
+
variant="subtle"
|
115 |
+
color="gray"
|
116 |
+
>
|
117 |
+
<IconHandStop size={16} />
|
118 |
+
</ActionIcon>
|
119 |
+
</Tooltip>
|
120 |
+
) : (
|
121 |
+
<Tooltip label="Regenerate response">
|
122 |
+
<ActionIcon
|
123 |
+
onClick={() => searchAndRespond()}
|
124 |
+
variant="subtle"
|
125 |
+
color="gray"
|
126 |
+
>
|
127 |
+
<IconRefresh size={16} />
|
128 |
+
</ActionIcon>
|
129 |
+
</Tooltip>
|
130 |
+
)}
|
131 |
+
<Tooltip
|
132 |
+
label={isSpeaking ? "Stop speaking" : "Listen to response"}
|
133 |
+
>
|
134 |
+
<ActionIcon
|
135 |
+
onClick={() => speakResponse(response)}
|
136 |
+
variant="subtle"
|
137 |
+
color={isSpeaking ? "blue" : "gray"}
|
138 |
+
>
|
139 |
+
<IconVolume2 size={16} />
|
140 |
+
</ActionIcon>
|
141 |
+
</Tooltip>
|
142 |
+
{settings.enableAiResponseScrolling ? (
|
143 |
+
<Tooltip label="Show full response without scroll bar">
|
144 |
+
<ActionIcon
|
145 |
+
onClick={() => {
|
146 |
+
setSettings({
|
147 |
+
...settings,
|
148 |
+
enableAiResponseScrolling: false,
|
149 |
+
});
|
150 |
+
}}
|
151 |
+
variant="subtle"
|
152 |
+
color="gray"
|
153 |
+
>
|
154 |
+
<IconArrowsMaximize size={16} />
|
155 |
+
</ActionIcon>
|
156 |
+
</Tooltip>
|
157 |
+
) : (
|
158 |
+
<Tooltip label="Enable scroll bar">
|
159 |
+
<ActionIcon
|
160 |
+
onClick={() => {
|
161 |
+
setSettings({
|
162 |
+
...settings,
|
163 |
+
enableAiResponseScrolling: true,
|
164 |
+
});
|
165 |
+
}}
|
166 |
+
variant="subtle"
|
167 |
+
color="gray"
|
168 |
+
>
|
169 |
+
<IconArrowsMinimize size={16} />
|
170 |
+
</ActionIcon>
|
171 |
+
</Tooltip>
|
172 |
+
)}
|
173 |
+
<Suspense>
|
174 |
+
<CopyIconButton value={response} tooltipLabel="Copy response" />
|
175 |
+
</Suspense>
|
176 |
+
</Group>
|
177 |
+
</Group>
|
178 |
+
</Card.Section>
|
179 |
+
<Card.Section withBorder>
|
180 |
+
<ConditionalScrollArea>
|
181 |
+
<Suspense>
|
182 |
+
<FormattedMarkdown>{response}</FormattedMarkdown>
|
183 |
+
</Suspense>
|
184 |
+
</ConditionalScrollArea>
|
185 |
+
{textGenerationState === "failed" && (
|
186 |
+
<Alert
|
187 |
+
variant="light"
|
188 |
+
color="yellow"
|
189 |
+
title="Failed to generate response"
|
190 |
+
icon={<IconInfoCircle />}
|
191 |
+
>
|
192 |
+
Could not generate response. It's possible that your browser or your
|
193 |
+
system is out of memory.
|
194 |
+
</Alert>
|
195 |
+
)}
|
196 |
+
</Card.Section>
|
197 |
+
</Card>
|
198 |
+
);
|
199 |
+
}
|
client/components/AiResponse/AiResponseSection.tsx
ADDED
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { usePubSub } from "create-pubsub/react";
|
2 |
+
import { Suspense, lazy, useMemo } from "react";
|
3 |
+
import {
|
4 |
+
modelLoadingProgressPubSub,
|
5 |
+
modelSizeInMegabytesPubSub,
|
6 |
+
queryPubSub,
|
7 |
+
responsePubSub,
|
8 |
+
settingsPubSub,
|
9 |
+
textGenerationStatePubSub,
|
10 |
+
} from "../../modules/pubSub";
|
11 |
+
|
12 |
+
const AiResponseContent = lazy(() => import("./AiResponseContent"));
|
13 |
+
const PreparingContent = lazy(() => import("./PreparingContent"));
|
14 |
+
const LoadingModelContent = lazy(() => import("./LoadingModelContent"));
|
15 |
+
const ChatInterface = lazy(() => import("./ChatInterface"));
|
16 |
+
const AiModelDownloadAllowanceContent = lazy(
|
17 |
+
() => import("./AiModelDownloadAllowanceContent"),
|
18 |
+
);
|
19 |
+
|
20 |
+
export default function AiResponseSection() {
|
21 |
+
const [query] = usePubSub(queryPubSub);
|
22 |
+
const [response] = usePubSub(responsePubSub);
|
23 |
+
const [textGenerationState, setTextGenerationState] = usePubSub(
|
24 |
+
textGenerationStatePubSub,
|
25 |
+
);
|
26 |
+
const [modelLoadingProgress] = usePubSub(modelLoadingProgressPubSub);
|
27 |
+
const [settings] = usePubSub(settingsPubSub);
|
28 |
+
const [modelSizeInMegabytes] = usePubSub(modelSizeInMegabytesPubSub);
|
29 |
+
|
30 |
+
return useMemo(() => {
|
31 |
+
if (!settings.enableAiResponse || textGenerationState === "idle") {
|
32 |
+
return null;
|
33 |
+
}
|
34 |
+
|
35 |
+
const generatingStates = [
|
36 |
+
"generating",
|
37 |
+
"interrupted",
|
38 |
+
"completed",
|
39 |
+
"failed",
|
40 |
+
];
|
41 |
+
if (generatingStates.includes(textGenerationState)) {
|
42 |
+
return (
|
43 |
+
<>
|
44 |
+
<Suspense>
|
45 |
+
<AiResponseContent
|
46 |
+
textGenerationState={textGenerationState}
|
47 |
+
response={response}
|
48 |
+
setTextGenerationState={setTextGenerationState}
|
49 |
+
/>
|
50 |
+
</Suspense>
|
51 |
+
{textGenerationState === "completed" && (
|
52 |
+
<Suspense>
|
53 |
+
<ChatInterface initialQuery={query} initialResponse={response} />
|
54 |
+
</Suspense>
|
55 |
+
)}
|
56 |
+
</>
|
57 |
+
);
|
58 |
+
}
|
59 |
+
|
60 |
+
if (textGenerationState === "loadingModel") {
|
61 |
+
return (
|
62 |
+
<Suspense>
|
63 |
+
<LoadingModelContent
|
64 |
+
modelLoadingProgress={modelLoadingProgress}
|
65 |
+
modelSizeInMegabytes={modelSizeInMegabytes}
|
66 |
+
/>
|
67 |
+
</Suspense>
|
68 |
+
);
|
69 |
+
}
|
70 |
+
|
71 |
+
if (textGenerationState === "preparingToGenerate") {
|
72 |
+
return (
|
73 |
+
<Suspense>
|
74 |
+
<PreparingContent textGenerationState={textGenerationState} />
|
75 |
+
</Suspense>
|
76 |
+
);
|
77 |
+
}
|
78 |
+
|
79 |
+
if (textGenerationState === "awaitingSearchResults") {
|
80 |
+
return (
|
81 |
+
<Suspense>
|
82 |
+
<PreparingContent textGenerationState={textGenerationState} />
|
83 |
+
</Suspense>
|
84 |
+
);
|
85 |
+
}
|
86 |
+
|
87 |
+
if (textGenerationState === "awaitingModelDownloadAllowance") {
|
88 |
+
return (
|
89 |
+
<Suspense>
|
90 |
+
<AiModelDownloadAllowanceContent />
|
91 |
+
</Suspense>
|
92 |
+
);
|
93 |
+
}
|
94 |
+
|
95 |
+
return null;
|
96 |
+
}, [
|
97 |
+
settings.enableAiResponse,
|
98 |
+
textGenerationState,
|
99 |
+
response,
|
100 |
+
query,
|
101 |
+
modelLoadingProgress,
|
102 |
+
modelSizeInMegabytes,
|
103 |
+
setTextGenerationState,
|
104 |
+
]);
|
105 |
+
}
|
client/components/AiResponse/ChatInterface.tsx
ADDED
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
Button,
|
3 |
+
Card,
|
4 |
+
Group,
|
5 |
+
Paper,
|
6 |
+
Stack,
|
7 |
+
Text,
|
8 |
+
Textarea,
|
9 |
+
} from "@mantine/core";
|
10 |
+
import { IconSend } from "@tabler/icons-react";
|
11 |
+
import { usePubSub } from "create-pubsub/react";
|
12 |
+
import type { ChatMessage } from "gpt-tokenizer/GptEncoding";
|
13 |
+
import {
|
14 |
+
type KeyboardEvent,
|
15 |
+
Suspense,
|
16 |
+
lazy,
|
17 |
+
useEffect,
|
18 |
+
useRef,
|
19 |
+
useState,
|
20 |
+
} from "react";
|
21 |
+
import { handleEnterKeyDown } from "../../modules/keyboard";
|
22 |
+
import { addLogEntry } from "../../modules/logEntries";
|
23 |
+
import { settingsPubSub } from "../../modules/pubSub";
|
24 |
+
import { generateChatResponse } from "../../modules/textGeneration";
|
25 |
+
|
26 |
+
const FormattedMarkdown = lazy(() => import("./FormattedMarkdown"));
|
27 |
+
const CopyIconButton = lazy(() => import("./CopyIconButton"));
|
28 |
+
|
29 |
+
export default function ChatInterface({
|
30 |
+
initialQuery,
|
31 |
+
initialResponse,
|
32 |
+
}: {
|
33 |
+
initialQuery: string;
|
34 |
+
initialResponse: string;
|
35 |
+
}) {
|
36 |
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
37 |
+
const [input, setInput] = useState("");
|
38 |
+
const [isGenerating, setIsGenerating] = useState(false);
|
39 |
+
const [streamedResponse, setStreamedResponse] = useState("");
|
40 |
+
const latestResponseRef = useRef("");
|
41 |
+
const [settings] = usePubSub(settingsPubSub);
|
42 |
+
|
43 |
+
useEffect(() => {
|
44 |
+
setMessages([
|
45 |
+
{ role: "user", content: initialQuery },
|
46 |
+
{ role: "assistant", content: initialResponse },
|
47 |
+
]);
|
48 |
+
}, [initialQuery, initialResponse]);
|
49 |
+
|
50 |
+
const handleSend = async () => {
|
51 |
+
if (input.trim() === "" || isGenerating) return;
|
52 |
+
|
53 |
+
const newMessages: ChatMessage[] = [
|
54 |
+
...messages,
|
55 |
+
{ role: "user", content: input },
|
56 |
+
];
|
57 |
+
setMessages(newMessages);
|
58 |
+
setInput("");
|
59 |
+
setIsGenerating(true);
|
60 |
+
setStreamedResponse("");
|
61 |
+
latestResponseRef.current = "";
|
62 |
+
|
63 |
+
try {
|
64 |
+
addLogEntry("User sent a follow-up question");
|
65 |
+
await generateChatResponse(newMessages, (partialResponse) => {
|
66 |
+
setStreamedResponse(partialResponse);
|
67 |
+
latestResponseRef.current = partialResponse;
|
68 |
+
});
|
69 |
+
setMessages((prevMessages) => [
|
70 |
+
...prevMessages,
|
71 |
+
{ role: "assistant", content: latestResponseRef.current },
|
72 |
+
]);
|
73 |
+
addLogEntry("AI responded to follow-up question");
|
74 |
+
} catch (error) {
|
75 |
+
addLogEntry(`Error generating chat response: ${error}`);
|
76 |
+
setMessages((prevMessages) => [
|
77 |
+
...prevMessages,
|
78 |
+
{
|
79 |
+
role: "assistant",
|
80 |
+
content: "Sorry, I encountered an error while generating a response.",
|
81 |
+
},
|
82 |
+
]);
|
83 |
+
} finally {
|
84 |
+
setIsGenerating(false);
|
85 |
+
setStreamedResponse("");
|
86 |
+
}
|
87 |
+
};
|
88 |
+
|
89 |
+
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
90 |
+
handleEnterKeyDown(event, settings, handleSend);
|
91 |
+
};
|
92 |
+
|
93 |
+
const getChatContent = () => {
|
94 |
+
return messages
|
95 |
+
.slice(2)
|
96 |
+
.map(
|
97 |
+
(msg, index) =>
|
98 |
+
`${index + 1}. ${msg.role?.toUpperCase()}\n\n${msg.content}`,
|
99 |
+
)
|
100 |
+
.join("\n\n");
|
101 |
+
};
|
102 |
+
|
103 |
+
return (
|
104 |
+
<Card withBorder shadow="sm" radius="md">
|
105 |
+
<Card.Section withBorder inheritPadding py="xs">
|
106 |
+
<Group justify="space-between">
|
107 |
+
<Text fw={500}>Follow-up questions</Text>
|
108 |
+
{messages.length > 2 && (
|
109 |
+
<Suspense>
|
110 |
+
<CopyIconButton
|
111 |
+
value={getChatContent()}
|
112 |
+
tooltipLabel="Copy conversation"
|
113 |
+
/>
|
114 |
+
</Suspense>
|
115 |
+
)}
|
116 |
+
</Group>
|
117 |
+
</Card.Section>
|
118 |
+
<Stack gap="md" pt="md">
|
119 |
+
{messages.slice(2).length > 0 && (
|
120 |
+
<Stack gap="md">
|
121 |
+
{messages.slice(2).map((message, index) => (
|
122 |
+
<Paper
|
123 |
+
key={`${message.role}-${index}`}
|
124 |
+
shadow="xs"
|
125 |
+
radius="xl"
|
126 |
+
p="sm"
|
127 |
+
maw="90%"
|
128 |
+
style={{
|
129 |
+
alignSelf:
|
130 |
+
message.role === "user" ? "flex-end" : "flex-start",
|
131 |
+
}}
|
132 |
+
>
|
133 |
+
<Suspense>
|
134 |
+
<FormattedMarkdown>{message.content}</FormattedMarkdown>
|
135 |
+
</Suspense>
|
136 |
+
</Paper>
|
137 |
+
))}
|
138 |
+
{isGenerating && streamedResponse.length > 0 && (
|
139 |
+
<Paper
|
140 |
+
shadow="xs"
|
141 |
+
radius="xl"
|
142 |
+
p="sm"
|
143 |
+
maw="90%"
|
144 |
+
style={{ alignSelf: "flex-start" }}
|
145 |
+
>
|
146 |
+
<Suspense>
|
147 |
+
<FormattedMarkdown>{streamedResponse}</FormattedMarkdown>
|
148 |
+
</Suspense>
|
149 |
+
</Paper>
|
150 |
+
)}
|
151 |
+
</Stack>
|
152 |
+
)}
|
153 |
+
<Group align="flex-end" style={{ position: "relative" }}>
|
154 |
+
<Textarea
|
155 |
+
placeholder="Anything else you would like to know?"
|
156 |
+
value={input}
|
157 |
+
onChange={(event) => setInput(event.currentTarget.value)}
|
158 |
+
onKeyDown={handleKeyDown}
|
159 |
+
autosize
|
160 |
+
minRows={1}
|
161 |
+
maxRows={4}
|
162 |
+
style={{ flexGrow: 1, paddingRight: "50px" }}
|
163 |
+
disabled={isGenerating}
|
164 |
+
/>
|
165 |
+
<Button
|
166 |
+
size="sm"
|
167 |
+
variant="default"
|
168 |
+
onClick={handleSend}
|
169 |
+
loading={isGenerating}
|
170 |
+
style={{
|
171 |
+
height: "100%",
|
172 |
+
position: "absolute",
|
173 |
+
right: 0,
|
174 |
+
top: 0,
|
175 |
+
bottom: 0,
|
176 |
+
borderTopLeftRadius: 0,
|
177 |
+
borderBottomLeftRadius: 0,
|
178 |
+
}}
|
179 |
+
>
|
180 |
+
<IconSend size={16} />
|
181 |
+
</Button>
|
182 |
+
</Group>
|
183 |
+
</Stack>
|
184 |
+
</Card>
|
185 |
+
);
|
186 |
+
}
|
client/components/AiResponse/CopyIconButton.tsx
ADDED
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
|
2 |
+
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
3 |
+
|
4 |
+
interface CopyIconButtonProps {
|
5 |
+
value: string;
|
6 |
+
tooltipLabel?: string;
|
7 |
+
}
|
8 |
+
|
9 |
+
export default function CopyIconButton({
|
10 |
+
value,
|
11 |
+
tooltipLabel = "Copy",
|
12 |
+
}: CopyIconButtonProps) {
|
13 |
+
return (
|
14 |
+
<CopyButton value={value} timeout={2000}>
|
15 |
+
{({ copied, copy }) => (
|
16 |
+
<Tooltip
|
17 |
+
label={copied ? "Copied" : tooltipLabel}
|
18 |
+
withArrow
|
19 |
+
position="right"
|
20 |
+
>
|
21 |
+
<ActionIcon
|
22 |
+
color={copied ? "teal" : "gray"}
|
23 |
+
variant="subtle"
|
24 |
+
onClick={copy}
|
25 |
+
>
|
26 |
+
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
27 |
+
</ActionIcon>
|
28 |
+
</Tooltip>
|
29 |
+
)}
|
30 |
+
</CopyButton>
|
31 |
+
);
|
32 |
+
}
|
client/components/AiResponse/FormattedMarkdown.tsx
ADDED
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Box, TypographyStylesProvider, useMantineTheme } from "@mantine/core";
|
2 |
+
import React from "react";
|
3 |
+
import Markdown from "react-markdown";
|
4 |
+
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
5 |
+
import syntaxHighlighterStyle from "react-syntax-highlighter/dist/esm/styles/prism/one-dark";
|
6 |
+
import remarkGfm from "remark-gfm";
|
7 |
+
import CopyIconButton from "./CopyIconButton";
|
8 |
+
|
9 |
+
interface FormattedMarkdownProps {
|
10 |
+
children: string;
|
11 |
+
className?: string;
|
12 |
+
enableCopy?: boolean;
|
13 |
+
}
|
14 |
+
|
15 |
+
const FormattedMarkdown: React.FC<FormattedMarkdownProps> = ({
|
16 |
+
children,
|
17 |
+
className = "",
|
18 |
+
enableCopy = true,
|
19 |
+
}) => {
|
20 |
+
const theme = useMantineTheme();
|
21 |
+
|
22 |
+
if (!children) {
|
23 |
+
return null;
|
24 |
+
}
|
25 |
+
|
26 |
+
return (
|
27 |
+
<TypographyStylesProvider p="md">
|
28 |
+
<Box className={className}>
|
29 |
+
<Markdown
|
30 |
+
remarkPlugins={[remarkGfm]}
|
31 |
+
components={{
|
32 |
+
li(props) {
|
33 |
+
const children = React.Children.map(props.children, (child) => {
|
34 |
+
const containsParagraphTag =
|
35 |
+
React.isValidElement<HTMLElement>(child) &&
|
36 |
+
child.type === "p";
|
37 |
+
return containsParagraphTag ? child.props.children : child;
|
38 |
+
});
|
39 |
+
|
40 |
+
return <li>{children}</li>;
|
41 |
+
},
|
42 |
+
pre(props) {
|
43 |
+
return <>{props.children}</>;
|
44 |
+
},
|
45 |
+
code(props) {
|
46 |
+
const { children, className, node, ref, ...rest } = props;
|
47 |
+
void node;
|
48 |
+
const languageMatch = /language-(\w+)/.exec(className || "");
|
49 |
+
const codeContent = children?.toString().replace(/\n$/, "") ?? "";
|
50 |
+
|
51 |
+
if (languageMatch) {
|
52 |
+
return (
|
53 |
+
<Box
|
54 |
+
style={{
|
55 |
+
position: "relative",
|
56 |
+
marginBottom: theme.spacing.md,
|
57 |
+
}}
|
58 |
+
>
|
59 |
+
{enableCopy && (
|
60 |
+
<Box
|
61 |
+
style={{
|
62 |
+
position: "absolute",
|
63 |
+
top: theme.spacing.xs,
|
64 |
+
right: theme.spacing.xs,
|
65 |
+
zIndex: 2,
|
66 |
+
}}
|
67 |
+
>
|
68 |
+
<CopyIconButton value={codeContent} />
|
69 |
+
</Box>
|
70 |
+
)}
|
71 |
+
<SyntaxHighlighter
|
72 |
+
{...rest}
|
73 |
+
ref={ref as never}
|
74 |
+
language={languageMatch[1]}
|
75 |
+
style={syntaxHighlighterStyle}
|
76 |
+
>
|
77 |
+
{codeContent}
|
78 |
+
</SyntaxHighlighter>
|
79 |
+
</Box>
|
80 |
+
);
|
81 |
+
}
|
82 |
+
|
83 |
+
return (
|
84 |
+
<code
|
85 |
+
{...rest}
|
86 |
+
className={className}
|
87 |
+
style={{
|
88 |
+
backgroundColor: theme.colors.gray[8],
|
89 |
+
padding: "0.2em 0.4em",
|
90 |
+
borderRadius: theme.radius.sm,
|
91 |
+
fontSize: "0.9em",
|
92 |
+
}}
|
93 |
+
>
|
94 |
+
{children}
|
95 |
+
</code>
|
96 |
+
);
|
97 |
+
},
|
98 |
+
}}
|
99 |
+
>
|
100 |
+
{children}
|
101 |
+
</Markdown>
|
102 |
+
</Box>
|
103 |
+
</TypographyStylesProvider>
|
104 |
+
);
|
105 |
+
};
|
106 |
+
|
107 |
+
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,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Card, Skeleton, Stack, Text } from "@mantine/core";
|
2 |
+
|
3 |
+
export default function PreparingContent({
|
4 |
+
textGenerationState,
|
5 |
+
}: {
|
6 |
+
textGenerationState: string;
|
7 |
+
}) {
|
8 |
+
const getStateText = () => {
|
9 |
+
if (textGenerationState === "awaitingSearchResults") {
|
10 |
+
return "Awaiting search results...";
|
11 |
+
}
|
12 |
+
if (textGenerationState === "preparingToGenerate") {
|
13 |
+
return "Preparing AI response...";
|
14 |
+
}
|
15 |
+
return null;
|
16 |
+
};
|
17 |
+
|
18 |
+
return (
|
19 |
+
<Card withBorder shadow="sm" radius="md">
|
20 |
+
<Card.Section withBorder inheritPadding py="xs">
|
21 |
+
<Text fw={500}>{getStateText()}</Text>
|
22 |
+
</Card.Section>
|
23 |
+
<Card.Section withBorder inheritPadding py="md">
|
24 |
+
<Stack>
|
25 |
+
<Skeleton height={8} radius="xl" />
|
26 |
+
<Skeleton height={8} width="70%" radius="xl" />
|
27 |
+
<Skeleton height={8} radius="xl" />
|
28 |
+
<Skeleton height={8} width="43%" radius="xl" />
|
29 |
+
</Stack>
|
30 |
+
</Card.Section>
|
31 |
+
</Card>
|
32 |
+
);
|
33 |
+
}
|
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,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { verifyStoredAccessKey } from "../../modules/accessKey";
|
12 |
+
|
13 |
+
const MainPage = lazy(() => import("../Pages/Main/MainPage"));
|
14 |
+
const AccessPage = lazy(() => import("../Pages/AccessPage"));
|
15 |
+
|
16 |
+
export function App() {
|
17 |
+
useInitializeSettings();
|
18 |
+
const { hasValidatedAccessKey, isCheckingStoredKey, setValidatedAccessKey } =
|
19 |
+
useAccessKeyValidation();
|
20 |
+
|
21 |
+
if (isCheckingStoredKey) {
|
22 |
+
return null;
|
23 |
+
}
|
24 |
+
|
25 |
+
return (
|
26 |
+
<MantineProvider defaultColorScheme="dark">
|
27 |
+
<Notifications />
|
28 |
+
<Switch>
|
29 |
+
<Route path="/">
|
30 |
+
{VITE_ACCESS_KEYS_ENABLED && !hasValidatedAccessKey ? (
|
31 |
+
<AccessPage onAccessKeyValid={() => setValidatedAccessKey(true)} />
|
32 |
+
) : (
|
33 |
+
<MainPage />
|
34 |
+
)}
|
35 |
+
</Route>
|
36 |
+
</Switch>
|
37 |
+
</MantineProvider>
|
38 |
+
);
|
39 |
+
}
|
40 |
+
|
41 |
+
/**
|
42 |
+
* A custom React hook that initializes the application settings.
|
43 |
+
*
|
44 |
+
* @returns The initialized settings object.
|
45 |
+
*
|
46 |
+
* @remarks
|
47 |
+
* This hook uses the `usePubSub` hook to access and update the settings state.
|
48 |
+
* It initializes the settings by merging the default settings with any existing settings.
|
49 |
+
* The initialization is performed once when the component mounts.
|
50 |
+
*/
|
51 |
+
function useInitializeSettings() {
|
52 |
+
const [settings, setSettings] = usePubSub(settingsPubSub);
|
53 |
+
const [settingsInitialized, setSettingsInitialized] = useState(false);
|
54 |
+
|
55 |
+
useEffect(() => {
|
56 |
+
if (settingsInitialized) return;
|
57 |
+
|
58 |
+
setSettings({ ...defaultSettings, ...settings });
|
59 |
+
|
60 |
+
setSettingsInitialized(true);
|
61 |
+
|
62 |
+
addLogEntry("Settings initialized");
|
63 |
+
}, [settings, setSettings, settingsInitialized]);
|
64 |
+
|
65 |
+
return settings;
|
66 |
+
}
|
67 |
+
|
68 |
+
/**
|
69 |
+
* A custom React hook that validates the stored access key on mount.
|
70 |
+
*
|
71 |
+
* @returns An object containing the validation state and loading state
|
72 |
+
*/
|
73 |
+
function useAccessKeyValidation() {
|
74 |
+
const [hasValidatedAccessKey, setValidatedAccessKey] = useState(false);
|
75 |
+
const [isCheckingStoredKey, setCheckingStoredKey] = useState(true);
|
76 |
+
|
77 |
+
useEffect(() => {
|
78 |
+
async function checkStoredAccessKey() {
|
79 |
+
if (VITE_ACCESS_KEYS_ENABLED) {
|
80 |
+
const isValid = await verifyStoredAccessKey();
|
81 |
+
if (isValid) setValidatedAccessKey(true);
|
82 |
+
}
|
83 |
+
setCheckingStoredKey(false);
|
84 |
+
}
|
85 |
+
|
86 |
+
checkStoredAccessKey();
|
87 |
+
}, []);
|
88 |
+
|
89 |
+
return {
|
90 |
+
hasValidatedAccessKey,
|
91 |
+
isCheckingStoredKey,
|
92 |
+
setValidatedAccessKey,
|
93 |
+
};
|
94 |
+
}
|
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,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Center, Container, Loader, Stack } from "@mantine/core";
|
2 |
+
import { usePubSub } from "create-pubsub/react";
|
3 |
+
import { Suspense } from "react";
|
4 |
+
import { lazy } from "react";
|
5 |
+
import {
|
6 |
+
imageSearchStatePubSub,
|
7 |
+
queryPubSub,
|
8 |
+
textGenerationStatePubSub,
|
9 |
+
textSearchStatePubSub,
|
10 |
+
} from "../../../modules/pubSub";
|
11 |
+
|
12 |
+
const AiResponseSection = lazy(
|
13 |
+
() => import("../../AiResponse/AiResponseSection"),
|
14 |
+
);
|
15 |
+
const SearchResultsSection = lazy(
|
16 |
+
() => import("../../Search/Results/SearchResultsSection"),
|
17 |
+
);
|
18 |
+
const MenuButton = lazy(() => import("./Menu/MenuButton"));
|
19 |
+
const SearchForm = lazy(() => import("../../Search/Form/SearchForm"));
|
20 |
+
|
21 |
+
export default function MainPage() {
|
22 |
+
const [query, updateQuery] = usePubSub(queryPubSub);
|
23 |
+
const [textSearchState] = usePubSub(textSearchStatePubSub);
|
24 |
+
const [imageSearchState] = usePubSub(imageSearchStatePubSub);
|
25 |
+
const [textGenerationState] = usePubSub(textGenerationStatePubSub);
|
26 |
+
|
27 |
+
return (
|
28 |
+
<Container>
|
29 |
+
<Stack
|
30 |
+
py="md"
|
31 |
+
mih="100vh"
|
32 |
+
justify={query.length === 0 ? "center" : undefined}
|
33 |
+
>
|
34 |
+
<Suspense
|
35 |
+
fallback={
|
36 |
+
<Center>
|
37 |
+
<Loader type="bars" />
|
38 |
+
</Center>
|
39 |
+
}
|
40 |
+
>
|
41 |
+
<SearchForm
|
42 |
+
query={query}
|
43 |
+
updateQuery={updateQuery}
|
44 |
+
additionalButtons={<MenuButton />}
|
45 |
+
/>
|
46 |
+
</Suspense>
|
47 |
+
{textGenerationState !== "idle" && (
|
48 |
+
<Suspense>
|
49 |
+
<AiResponseSection />
|
50 |
+
</Suspense>
|
51 |
+
)}
|
52 |
+
{(textSearchState !== "idle" || imageSearchState !== "idle") && (
|
53 |
+
<Suspense>
|
54 |
+
<SearchResultsSection />
|
55 |
+
</Suspense>
|
56 |
+
)}
|
57 |
+
</Stack>
|
58 |
+
</Container>
|
59 |
+
);
|
60 |
+
}
|
client/components/Pages/Main/Menu/AISettingsForm.tsx
ADDED
@@ -0,0 +1,441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { addLogEntry } from "../../../../modules/logEntries";
|
18 |
+
import { getOpenAiClient } from "../../../../modules/openai";
|
19 |
+
import { settingsPubSub } from "../../../../modules/pubSub";
|
20 |
+
import { defaultSettings, inferenceTypes } from "../../../../modules/settings";
|
21 |
+
import { isWebGPUAvailable } from "../../../../modules/webGpu";
|
22 |
+
|
23 |
+
const WebLlmModelSelect = lazy(
|
24 |
+
() => import("../../../AiResponse/WebLlmModelSelect"),
|
25 |
+
);
|
26 |
+
const WllamaModelSelect = lazy(
|
27 |
+
() => import("../../../AiResponse/WllamaModelSelect"),
|
28 |
+
);
|
29 |
+
|
30 |
+
const penaltySliderMarks = [
|
31 |
+
{ value: -2.0, label: "-2.0" },
|
32 |
+
{ value: 0.0, label: "0" },
|
33 |
+
{ value: 2.0, label: "2.0" },
|
34 |
+
];
|
35 |
+
|
36 |
+
export default function AISettingsForm() {
|
37 |
+
const [settings, setSettings] = usePubSub(settingsPubSub);
|
38 |
+
const [openAiModels, setOpenAiModels] = useState<
|
39 |
+
{
|
40 |
+
label: string;
|
41 |
+
value: string;
|
42 |
+
}[]
|
43 |
+
>([]);
|
44 |
+
const [openAiApiModelError, setOpenAiApiModelError] = useState<
|
45 |
+
string | undefined
|
46 |
+
>(undefined);
|
47 |
+
|
48 |
+
const form = useForm({
|
49 |
+
initialValues: settings,
|
50 |
+
onValuesChange: setSettings,
|
51 |
+
});
|
52 |
+
|
53 |
+
useEffect(() => {
|
54 |
+
async function fetchOpenAiModels() {
|
55 |
+
try {
|
56 |
+
const openai = getOpenAiClient({
|
57 |
+
baseURL: settings.openAiApiBaseUrl,
|
58 |
+
apiKey: settings.openAiApiKey,
|
59 |
+
});
|
60 |
+
const response = await openai.models.list();
|
61 |
+
const models = response.data.map((model) => ({
|
62 |
+
label: model.id,
|
63 |
+
value: model.id,
|
64 |
+
}));
|
65 |
+
setOpenAiModels(models);
|
66 |
+
setOpenAiApiModelError(undefined);
|
67 |
+
} catch (error) {
|
68 |
+
const errorMessage =
|
69 |
+
error instanceof Error ? error.message : String(error);
|
70 |
+
addLogEntry(`Error fetching OpenAI models: ${errorMessage}`);
|
71 |
+
setOpenAiModels([]);
|
72 |
+
setOpenAiApiModelError(errorMessage);
|
73 |
+
}
|
74 |
+
}
|
75 |
+
|
76 |
+
if (settings.inferenceType === "openai" && settings.openAiApiBaseUrl) {
|
77 |
+
fetchOpenAiModels();
|
78 |
+
}
|
79 |
+
}, [
|
80 |
+
settings.inferenceType,
|
81 |
+
settings.openAiApiBaseUrl,
|
82 |
+
settings.openAiApiKey,
|
83 |
+
]);
|
84 |
+
|
85 |
+
useEffect(() => {
|
86 |
+
if (openAiApiModelError === form.errors.openAiApiModel) return;
|
87 |
+
|
88 |
+
form.setFieldError("openAiApiModel", openAiApiModelError);
|
89 |
+
}, [openAiApiModelError, form.setFieldError, form.errors.openAiApiModel]);
|
90 |
+
|
91 |
+
useEffect(() => {
|
92 |
+
if (openAiModels.length > 0) {
|
93 |
+
const hasNoModelSelected = !form.values.openAiApiModel;
|
94 |
+
const isModelInvalid = !openAiModels.find(
|
95 |
+
(model) => model.value === form.values.openAiApiModel,
|
96 |
+
);
|
97 |
+
|
98 |
+
if (hasNoModelSelected || isModelInvalid) {
|
99 |
+
form.setFieldValue("openAiApiModel", openAiModels[0].value);
|
100 |
+
}
|
101 |
+
}
|
102 |
+
}, [openAiModels, form.setFieldValue, form.values.openAiApiModel]);
|
103 |
+
|
104 |
+
const isUsingCustomInstructions =
|
105 |
+
form.values.systemPrompt !== defaultSettings.systemPrompt;
|
106 |
+
|
107 |
+
const handleRestoreDefaultInstructions = () => {
|
108 |
+
form.setFieldValue("systemPrompt", defaultSettings.systemPrompt);
|
109 |
+
};
|
110 |
+
|
111 |
+
const suggestedCpuThreads =
|
112 |
+
(navigator.hardwareConcurrency &&
|
113 |
+
Math.max(
|
114 |
+
defaultSettings.cpuThreads,
|
115 |
+
navigator.hardwareConcurrency - 2,
|
116 |
+
)) ??
|
117 |
+
defaultSettings.cpuThreads;
|
118 |
+
|
119 |
+
return (
|
120 |
+
<Stack gap="md">
|
121 |
+
<Switch
|
122 |
+
label="AI Response"
|
123 |
+
{...form.getInputProps("enableAiResponse", {
|
124 |
+
type: "checkbox",
|
125 |
+
})}
|
126 |
+
labelPosition="left"
|
127 |
+
description="Enable or disable AI-generated responses to your queries. When disabled, you'll only see web search results."
|
128 |
+
/>
|
129 |
+
|
130 |
+
{form.values.enableAiResponse && (
|
131 |
+
<>
|
132 |
+
<Stack gap="xs" mb="md">
|
133 |
+
<Text size="sm">Search results to consider</Text>
|
134 |
+
<Text size="xs" c="dimmed">
|
135 |
+
Determines the number of search results to consider when
|
136 |
+
generating AI responses. A higher value may enhance accuracy, but
|
137 |
+
it will also increase response time.
|
138 |
+
</Text>
|
139 |
+
<Slider
|
140 |
+
{...form.getInputProps("searchResultsToConsider")}
|
141 |
+
min={0}
|
142 |
+
max={6}
|
143 |
+
marks={Array.from({ length: 7 }, (_, index) => ({
|
144 |
+
value: index,
|
145 |
+
label: index.toString(),
|
146 |
+
}))}
|
147 |
+
/>
|
148 |
+
</Stack>
|
149 |
+
|
150 |
+
<Select
|
151 |
+
{...form.getInputProps("inferenceType")}
|
152 |
+
label="AI Processing Location"
|
153 |
+
data={inferenceTypes}
|
154 |
+
allowDeselect={false}
|
155 |
+
/>
|
156 |
+
|
157 |
+
{form.values.inferenceType === "openai" && (
|
158 |
+
<>
|
159 |
+
<TextInput
|
160 |
+
{...form.getInputProps("openAiApiBaseUrl")}
|
161 |
+
label="API Base URL"
|
162 |
+
placeholder="http://localhost:11434/v1"
|
163 |
+
required
|
164 |
+
/>
|
165 |
+
<Group gap="xs">
|
166 |
+
<IconInfoCircle size={16} />
|
167 |
+
<Text size="xs" c="dimmed" flex={1}>
|
168 |
+
You may need to add{" "}
|
169 |
+
<em>
|
170 |
+
{`${self.location.protocol}//${self.location.hostname}`}
|
171 |
+
</em>{" "}
|
172 |
+
to the list of allowed network origins in your API server
|
173 |
+
settings.
|
174 |
+
</Text>
|
175 |
+
</Group>
|
176 |
+
<TextInput
|
177 |
+
{...form.getInputProps("openAiApiKey")}
|
178 |
+
label="API Key"
|
179 |
+
type="password"
|
180 |
+
description="Optional, as local API servers usually do not require it."
|
181 |
+
/>
|
182 |
+
<Select
|
183 |
+
{...form.getInputProps("openAiApiModel")}
|
184 |
+
label="API Model"
|
185 |
+
data={openAiModels}
|
186 |
+
description="Optional, as some API servers don't provide a model list."
|
187 |
+
allowDeselect={false}
|
188 |
+
disabled={openAiModels.length === 0}
|
189 |
+
searchable
|
190 |
+
/>
|
191 |
+
</>
|
192 |
+
)}
|
193 |
+
|
194 |
+
{form.values.inferenceType === "browser" && (
|
195 |
+
<>
|
196 |
+
{isWebGPUAvailable && (
|
197 |
+
<Switch
|
198 |
+
label="WebGPU"
|
199 |
+
{...form.getInputProps("enableWebGpu", {
|
200 |
+
type: "checkbox",
|
201 |
+
})}
|
202 |
+
labelPosition="left"
|
203 |
+
description="Enable or disable WebGPU usage. When disabled, the app will use the CPU instead."
|
204 |
+
/>
|
205 |
+
)}
|
206 |
+
|
207 |
+
{isWebGPUAvailable && form.values.enableWebGpu ? (
|
208 |
+
<Suspense fallback={<Skeleton height={50} />}>
|
209 |
+
<WebLlmModelSelect
|
210 |
+
value={form.values.webLlmModelId}
|
211 |
+
onChange={(value) =>
|
212 |
+
form.setFieldValue("webLlmModelId", value)
|
213 |
+
}
|
214 |
+
/>
|
215 |
+
</Suspense>
|
216 |
+
) : (
|
217 |
+
<>
|
218 |
+
<Suspense fallback={<Skeleton height={50} />}>
|
219 |
+
<WllamaModelSelect
|
220 |
+
value={form.values.wllamaModelId}
|
221 |
+
onChange={(value) =>
|
222 |
+
form.setFieldValue("wllamaModelId", value)
|
223 |
+
}
|
224 |
+
/>
|
225 |
+
</Suspense>
|
226 |
+
<NumberInput
|
227 |
+
label="CPU threads to use"
|
228 |
+
description={
|
229 |
+
<>
|
230 |
+
<span>
|
231 |
+
Number of threads to use for the AI model. Lower
|
232 |
+
values will use less CPU but may take longer to
|
233 |
+
respond. A value that is too high may cause the app to
|
234 |
+
hang.
|
235 |
+
</span>
|
236 |
+
{suggestedCpuThreads > defaultSettings.cpuThreads && (
|
237 |
+
<span>
|
238 |
+
{" "}
|
239 |
+
The default value is{" "}
|
240 |
+
<Text
|
241 |
+
component="span"
|
242 |
+
size="xs"
|
243 |
+
c="blue"
|
244 |
+
style={{ cursor: "pointer" }}
|
245 |
+
onClick={() =>
|
246 |
+
form.setFieldValue(
|
247 |
+
"cpuThreads",
|
248 |
+
defaultSettings.cpuThreads,
|
249 |
+
)
|
250 |
+
}
|
251 |
+
>
|
252 |
+
{defaultSettings.cpuThreads}
|
253 |
+
</Text>
|
254 |
+
, but based on the number of logical processors in
|
255 |
+
your CPU, the suggested value is{" "}
|
256 |
+
<Text
|
257 |
+
component="span"
|
258 |
+
size="xs"
|
259 |
+
c="blue"
|
260 |
+
style={{ cursor: "pointer" }}
|
261 |
+
onClick={() =>
|
262 |
+
form.setFieldValue(
|
263 |
+
"cpuThreads",
|
264 |
+
suggestedCpuThreads,
|
265 |
+
)
|
266 |
+
}
|
267 |
+
>
|
268 |
+
{suggestedCpuThreads}
|
269 |
+
</Text>
|
270 |
+
.
|
271 |
+
</span>
|
272 |
+
)}
|
273 |
+
</>
|
274 |
+
}
|
275 |
+
min={1}
|
276 |
+
{...form.getInputProps("cpuThreads")}
|
277 |
+
/>
|
278 |
+
</>
|
279 |
+
)}
|
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 |
+
<Stack gap="xs" mb="md">
|
366 |
+
<Text size="sm">Temperature</Text>
|
367 |
+
<Text size="xs" c="dimmed">
|
368 |
+
Controls randomness in responses. Lower values make responses more
|
369 |
+
focused and deterministic, while higher values make them more
|
370 |
+
creative and diverse. Defaults to{" "}
|
371 |
+
{defaultSettings.inferenceTemperature}.
|
372 |
+
</Text>
|
373 |
+
<Slider
|
374 |
+
{...form.getInputProps("inferenceTemperature")}
|
375 |
+
min={0}
|
376 |
+
max={2}
|
377 |
+
step={0.01}
|
378 |
+
marks={[
|
379 |
+
{ value: 0, label: "0" },
|
380 |
+
{ value: 1, label: "1" },
|
381 |
+
{ value: 2, label: "2" },
|
382 |
+
]}
|
383 |
+
/>
|
384 |
+
</Stack>
|
385 |
+
|
386 |
+
<Stack gap="xs" mb="md">
|
387 |
+
<Text size="sm">Top P</Text>
|
388 |
+
<Text size="xs" c="dimmed">
|
389 |
+
Controls diversity by limiting cumulative probability of tokens.
|
390 |
+
Lower values make responses more focused, while higher values
|
391 |
+
allow more variety. Defaults to {defaultSettings.inferenceTopP}.
|
392 |
+
</Text>
|
393 |
+
<Slider
|
394 |
+
{...form.getInputProps("inferenceTopP")}
|
395 |
+
min={0}
|
396 |
+
max={1}
|
397 |
+
step={0.01}
|
398 |
+
marks={Array.from({ length: 3 }, (_, index) => ({
|
399 |
+
value: index / 2,
|
400 |
+
label: (index / 2).toString(),
|
401 |
+
}))}
|
402 |
+
/>
|
403 |
+
</Stack>
|
404 |
+
|
405 |
+
<Stack gap="xs" mb="md">
|
406 |
+
<Text size="sm">Frequency Penalty</Text>
|
407 |
+
<Text size="xs" c="dimmed">
|
408 |
+
Reduces repetition by penalizing tokens based on their frequency.
|
409 |
+
Higher values decrease the likelihood of repeating the same
|
410 |
+
information. Defaults to{" "}
|
411 |
+
{defaultSettings.inferenceFrequencyPenalty}.
|
412 |
+
</Text>
|
413 |
+
<Slider
|
414 |
+
{...form.getInputProps("inferenceFrequencyPenalty")}
|
415 |
+
min={-2.0}
|
416 |
+
max={2.0}
|
417 |
+
step={0.01}
|
418 |
+
marks={penaltySliderMarks}
|
419 |
+
/>
|
420 |
+
</Stack>
|
421 |
+
|
422 |
+
<Stack gap="xs" mb="md">
|
423 |
+
<Text size="sm">Presence Penalty</Text>
|
424 |
+
<Text size="xs" c="dimmed">
|
425 |
+
Encourages new topics by penalizing tokens that have appeared.
|
426 |
+
Higher values increase the model's likelihood to talk about new
|
427 |
+
topics. Defaults to {defaultSettings.inferencePresencePenalty}.
|
428 |
+
</Text>
|
429 |
+
<Slider
|
430 |
+
{...form.getInputProps("inferencePresencePenalty")}
|
431 |
+
min={-2.0}
|
432 |
+
max={2.0}
|
433 |
+
step={0.01}
|
434 |
+
marks={penaltySliderMarks}
|
435 |
+
/>
|
436 |
+
</Stack>
|
437 |
+
</>
|
438 |
+
)}
|
439 |
+
</Stack>
|
440 |
+
);
|
441 |
+
}
|
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,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
const { clearWllamaCache } = await import("../../../../modules/wllama");
|
33 |
+
|
34 |
+
await clearWllamaCache();
|
35 |
+
|
36 |
+
setIsClearingData(false);
|
37 |
+
|
38 |
+
setHasClearedData(true);
|
39 |
+
|
40 |
+
addLogEntry("All data cleared successfully");
|
41 |
+
|
42 |
+
navigate("/", { replace: true });
|
43 |
+
|
44 |
+
self.location.reload();
|
45 |
+
};
|
46 |
+
|
47 |
+
return (
|
48 |
+
<Stack gap="xs">
|
49 |
+
<Button
|
50 |
+
onClick={handleClearDataButtonClick}
|
51 |
+
variant="default"
|
52 |
+
loading={isClearingData}
|
53 |
+
loaderProps={{ type: "bars" }}
|
54 |
+
disabled={hasClearedData}
|
55 |
+
>
|
56 |
+
{hasClearedData ? "Data cleared" : "Clear all data"}
|
57 |
+
</Button>
|
58 |
+
<Text size="xs" c="dimmed">
|
59 |
+
Reset settings and delete all files in cache to free up space.
|
60 |
+
</Text>
|
61 |
+
</Stack>
|
62 |
+
);
|
63 |
+
}
|
client/components/Pages/Main/Menu/InterfaceSettingsForm.tsx
ADDED
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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("enterToSubmit", {
|
37 |
+
type: "checkbox",
|
38 |
+
})}
|
39 |
+
label="Enter to Submit"
|
40 |
+
labelPosition="left"
|
41 |
+
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."
|
42 |
+
/>
|
43 |
+
</Stack>
|
44 |
+
);
|
45 |
+
}
|
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,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 SearchSettingsForm = lazy(() => import("./SearchSettingsForm"));
|
21 |
+
const InterfaceSettingsForm = lazy(() => import("./InterfaceSettingsForm"));
|
22 |
+
const ActionsForm = lazy(() => import("./ActionsForm"));
|
23 |
+
const VoiceSettingsForm = lazy(() => import("./VoiceSettingsForm"));
|
24 |
+
|
25 |
+
export default function MenuDrawer(drawerProps: DrawerProps) {
|
26 |
+
const repoName = repository.url.split("/").pop();
|
27 |
+
|
28 |
+
return (
|
29 |
+
<Drawer
|
30 |
+
{...drawerProps}
|
31 |
+
position="right"
|
32 |
+
size="md"
|
33 |
+
title={
|
34 |
+
<Group gap="xs">
|
35 |
+
<ActionIcon
|
36 |
+
variant="subtle"
|
37 |
+
component="a"
|
38 |
+
color="var(--mantine-color-text)"
|
39 |
+
href={repository.url}
|
40 |
+
target="_blank"
|
41 |
+
onClick={() => addLogEntry("User clicked the GitHub link")}
|
42 |
+
>
|
43 |
+
<IconBrandGithub size={16} />
|
44 |
+
</ActionIcon>
|
45 |
+
<HoverCard shadow="md" withArrow>
|
46 |
+
<HoverCard.Target>
|
47 |
+
<Center>{repoName}</Center>
|
48 |
+
</HoverCard.Target>
|
49 |
+
<HoverCard.Dropdown>
|
50 |
+
<Stack gap="xs">
|
51 |
+
<Center>{repoName}</Center>
|
52 |
+
<Center>
|
53 |
+
{`v${getSemanticVersion(VITE_BUILD_DATE_TIME)}+${VITE_COMMIT_SHORT_HASH}`}
|
54 |
+
</Center>
|
55 |
+
<Center>
|
56 |
+
Released{" "}
|
57 |
+
{prettyMilliseconds(
|
58 |
+
new Date().getTime() -
|
59 |
+
new Date(VITE_BUILD_DATE_TIME).getTime(),
|
60 |
+
{
|
61 |
+
compact: true,
|
62 |
+
verbose: true,
|
63 |
+
},
|
64 |
+
)}{" "}
|
65 |
+
ago
|
66 |
+
</Center>
|
67 |
+
</Stack>
|
68 |
+
</HoverCard.Dropdown>
|
69 |
+
</HoverCard>
|
70 |
+
</Group>
|
71 |
+
}
|
72 |
+
>
|
73 |
+
<FocusTrap.InitialFocus />
|
74 |
+
<Drawer.Body>
|
75 |
+
<Accordion variant="separated" multiple>
|
76 |
+
<Accordion.Item value="aiSettings">
|
77 |
+
<Accordion.Control>AI Settings</Accordion.Control>
|
78 |
+
<Accordion.Panel>
|
79 |
+
<Suspense>
|
80 |
+
<AISettingsForm />
|
81 |
+
</Suspense>
|
82 |
+
</Accordion.Panel>
|
83 |
+
</Accordion.Item>
|
84 |
+
<Accordion.Item value="searchSettings">
|
85 |
+
<Accordion.Control>Search Settings</Accordion.Control>
|
86 |
+
<Accordion.Panel>
|
87 |
+
<Suspense>
|
88 |
+
<SearchSettingsForm />
|
89 |
+
</Suspense>
|
90 |
+
</Accordion.Panel>
|
91 |
+
</Accordion.Item>
|
92 |
+
<Accordion.Item value="interfaceSettings">
|
93 |
+
<Accordion.Control>Interface Settings</Accordion.Control>
|
94 |
+
<Accordion.Panel>
|
95 |
+
<Suspense>
|
96 |
+
<InterfaceSettingsForm />
|
97 |
+
</Suspense>
|
98 |
+
</Accordion.Panel>
|
99 |
+
</Accordion.Item>
|
100 |
+
<Accordion.Item value="voiceSettings">
|
101 |
+
<Accordion.Control>Voice Settings</Accordion.Control>
|
102 |
+
<Accordion.Panel>
|
103 |
+
<Suspense>
|
104 |
+
<VoiceSettingsForm />
|
105 |
+
</Suspense>
|
106 |
+
</Accordion.Panel>
|
107 |
+
</Accordion.Item>
|
108 |
+
<Accordion.Item value="actions">
|
109 |
+
<Accordion.Control>Actions</Accordion.Control>
|
110 |
+
<Accordion.Panel>
|
111 |
+
<Suspense>
|
112 |
+
<ActionsForm />
|
113 |
+
</Suspense>
|
114 |
+
</Accordion.Panel>
|
115 |
+
</Accordion.Item>
|
116 |
+
</Accordion>
|
117 |
+
</Drawer.Body>
|
118 |
+
</Drawer>
|
119 |
+
);
|
120 |
+
}
|
client/components/Pages/Main/Menu/SearchSettingsForm.tsx
ADDED
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Slider, Stack, Switch, Text } from "@mantine/core";
|
2 |
+
import { useForm } from "@mantine/form";
|
3 |
+
import { usePubSub } from "create-pubsub/react";
|
4 |
+
import { settingsPubSub } from "../../../../modules/pubSub";
|
5 |
+
|
6 |
+
export default function SearchSettingsForm() {
|
7 |
+
const [settings, setSettings] = usePubSub(settingsPubSub);
|
8 |
+
const form = useForm({
|
9 |
+
initialValues: settings,
|
10 |
+
onValuesChange: setSettings,
|
11 |
+
});
|
12 |
+
|
13 |
+
return (
|
14 |
+
<Stack gap="md">
|
15 |
+
<Stack gap="xs" mb="md">
|
16 |
+
<Text size="sm">Search Results Limit</Text>
|
17 |
+
<Text size="xs" c="dimmed">
|
18 |
+
Maximum number of search results to fetch. A higher value provides
|
19 |
+
more results but may increase search time.
|
20 |
+
</Text>
|
21 |
+
<Slider
|
22 |
+
{...form.getInputProps("searchResultsLimit")}
|
23 |
+
min={5}
|
24 |
+
max={30}
|
25 |
+
step={5}
|
26 |
+
marks={[5, 10, 15, 20, 25, 30].map((value) => ({
|
27 |
+
value,
|
28 |
+
label: value.toString(),
|
29 |
+
}))}
|
30 |
+
/>
|
31 |
+
</Stack>
|
32 |
+
|
33 |
+
<Switch
|
34 |
+
{...form.getInputProps("enableTextSearch", {
|
35 |
+
type: "checkbox",
|
36 |
+
})}
|
37 |
+
label="Text Search"
|
38 |
+
labelPosition="left"
|
39 |
+
description="Enable or disable text search results. When enabled, relevant web pages will be displayed in the search results."
|
40 |
+
/>
|
41 |
+
|
42 |
+
<Switch
|
43 |
+
{...form.getInputProps("enableImageSearch", {
|
44 |
+
type: "checkbox",
|
45 |
+
})}
|
46 |
+
label="Image Search"
|
47 |
+
labelPosition="left"
|
48 |
+
description="Enable or disable image search results. When enabled, relevant images will be displayed alongside web search results."
|
49 |
+
/>
|
50 |
+
</Stack>
|
51 |
+
);
|
52 |
+
}
|
client/components/Pages/Main/Menu/VoiceSettingsForm.tsx
ADDED
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Select, Stack, Text } from "@mantine/core";
|
2 |
+
import { useForm } from "@mantine/form";
|
3 |
+
import getUnicodeFlagIcon from "country-flag-icons/unicode";
|
4 |
+
import { usePubSub } from "create-pubsub/react";
|
5 |
+
import { useCallback, useEffect, useState } from "react";
|
6 |
+
import { settingsPubSub } from "../../../../modules/pubSub";
|
7 |
+
|
8 |
+
export default function VoiceSettingsForm() {
|
9 |
+
const [settings, setSettings] = usePubSub(settingsPubSub);
|
10 |
+
const [voices, setVoices] = useState<{ value: string; label: string }[]>([]);
|
11 |
+
|
12 |
+
const getCountryFlag = useCallback((langCode: string) => {
|
13 |
+
try {
|
14 |
+
const country = langCode.split("-")[1];
|
15 |
+
|
16 |
+
if (country.length !== 2) throw new Error("Invalid country code");
|
17 |
+
|
18 |
+
return getUnicodeFlagIcon(country);
|
19 |
+
} catch {
|
20 |
+
return "π";
|
21 |
+
}
|
22 |
+
}, []);
|
23 |
+
|
24 |
+
const form = useForm({
|
25 |
+
initialValues: settings,
|
26 |
+
onValuesChange: setSettings,
|
27 |
+
});
|
28 |
+
|
29 |
+
useEffect(() => {
|
30 |
+
const updateVoices = () => {
|
31 |
+
const availableVoices = self.speechSynthesis.getVoices();
|
32 |
+
const uniqueVoices = Array.from(
|
33 |
+
new Map(
|
34 |
+
availableVoices.map((voice) => [voice.voiceURI, voice]),
|
35 |
+
).values(),
|
36 |
+
);
|
37 |
+
const voiceOptions = uniqueVoices
|
38 |
+
.sort((a, b) => a.lang.localeCompare(b.lang))
|
39 |
+
.map((voice) => ({
|
40 |
+
value: voice.voiceURI,
|
41 |
+
label: `${getCountryFlag(voice.lang)} ${voice.name} β’ ${voice.lang}`,
|
42 |
+
}));
|
43 |
+
setVoices(voiceOptions);
|
44 |
+
};
|
45 |
+
|
46 |
+
updateVoices();
|
47 |
+
|
48 |
+
self.speechSynthesis.onvoiceschanged = updateVoices;
|
49 |
+
|
50 |
+
return () => {
|
51 |
+
self.speechSynthesis.onvoiceschanged = null;
|
52 |
+
};
|
53 |
+
}, [getCountryFlag]);
|
54 |
+
|
55 |
+
return (
|
56 |
+
<Stack gap="xs">
|
57 |
+
<Text size="sm">Voice Selection</Text>
|
58 |
+
<Text size="xs" c="dimmed">
|
59 |
+
Choose the voice to use when reading AI responses aloud.
|
60 |
+
</Text>
|
61 |
+
<Select
|
62 |
+
{...form.getInputProps("selectedVoiceId")}
|
63 |
+
data={voices}
|
64 |
+
searchable
|
65 |
+
nothingFoundMessage="No voices found"
|
66 |
+
placeholder="Auto-detected"
|
67 |
+
allowDeselect={true}
|
68 |
+
clearable
|
69 |
+
/>
|
70 |
+
</Stack>
|
71 |
+
);
|
72 |
+
}
|
client/components/Search/Form/SearchForm.tsx
ADDED
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { useLocation } from "wouter";
|
13 |
+
import { handleEnterKeyDown } from "../../../modules/keyboard";
|
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 |
+
const handleMount = useCallback(async () => {
|
38 |
+
await sleepUntilIdle();
|
39 |
+
searchAndRespond();
|
40 |
+
}, []);
|
41 |
+
|
42 |
+
const handleInitialSuggestion = useCallback(async () => {
|
43 |
+
const suggestion = await getRandomQuerySuggestion();
|
44 |
+
setSuggestedQuery(suggestion);
|
45 |
+
}, []);
|
46 |
+
|
47 |
+
useEffect(() => {
|
48 |
+
handleMount();
|
49 |
+
handleInitialSuggestion();
|
50 |
+
}, [handleMount, handleInitialSuggestion]);
|
51 |
+
|
52 |
+
const handleInputChange = async (event: ChangeEvent<HTMLTextAreaElement>) => {
|
53 |
+
const text = event.target.value;
|
54 |
+
|
55 |
+
setTextAreaValue(text);
|
56 |
+
|
57 |
+
if (text.length === 0) {
|
58 |
+
setSuggestedQuery(await getRandomQuerySuggestion());
|
59 |
+
}
|
60 |
+
};
|
61 |
+
|
62 |
+
const handleClearButtonClick = async () => {
|
63 |
+
setSuggestedQuery(await getRandomQuerySuggestion());
|
64 |
+
setTextAreaValue("");
|
65 |
+
textAreaRef.current?.focus();
|
66 |
+
addLogEntry("User cleaned the search query field");
|
67 |
+
};
|
68 |
+
|
69 |
+
const startSearching = useCallback(() => {
|
70 |
+
const queryToEncode =
|
71 |
+
textAreaValue.trim().length >= 1 ? textAreaValue : suggestedQuery;
|
72 |
+
|
73 |
+
setTextAreaValue(queryToEncode);
|
74 |
+
|
75 |
+
const queryString = `q=${encodeURIComponent(queryToEncode)}`;
|
76 |
+
|
77 |
+
postMessageToParentWindow({ queryString, hash: "" });
|
78 |
+
|
79 |
+
navigate(`/?${queryString}`, { replace: true });
|
80 |
+
|
81 |
+
updateQuery(queryToEncode);
|
82 |
+
|
83 |
+
searchAndRespond();
|
84 |
+
|
85 |
+
addLogEntry(
|
86 |
+
`User submitted a search with ${queryToEncode.length} characters length`,
|
87 |
+
);
|
88 |
+
}, [textAreaValue, suggestedQuery, updateQuery, navigate]);
|
89 |
+
|
90 |
+
const handleSubmit = (event: { preventDefault: () => void }) => {
|
91 |
+
event.preventDefault();
|
92 |
+
startSearching();
|
93 |
+
};
|
94 |
+
|
95 |
+
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
96 |
+
handleEnterKeyDown(event, settings, () => handleSubmit(event));
|
97 |
+
};
|
98 |
+
|
99 |
+
return (
|
100 |
+
<form onSubmit={handleSubmit} style={{ width: "100%" }}>
|
101 |
+
<Stack gap="xs">
|
102 |
+
<Textarea
|
103 |
+
value={textAreaValue}
|
104 |
+
placeholder={suggestedQuery}
|
105 |
+
ref={textAreaRef}
|
106 |
+
onKeyDown={handleKeyDown}
|
107 |
+
onChange={handleInputChange}
|
108 |
+
autosize
|
109 |
+
minRows={1}
|
110 |
+
maxRows={8}
|
111 |
+
autoFocus
|
112 |
+
/>
|
113 |
+
<Group gap="xs">
|
114 |
+
{textAreaValue.length >= 1 ? (
|
115 |
+
<Button
|
116 |
+
size="xs"
|
117 |
+
onClick={handleClearButtonClick}
|
118 |
+
variant="default"
|
119 |
+
>
|
120 |
+
Clear
|
121 |
+
</Button>
|
122 |
+
) : null}
|
123 |
+
<Button size="xs" type="submit" variant="default" flex={1}>
|
124 |
+
Search
|
125 |
+
</Button>
|
126 |
+
{additionalButtons}
|
127 |
+
</Group>
|
128 |
+
</Stack>
|
129 |
+
</form>
|
130 |
+
);
|
131 |
+
}
|
client/components/Search/Results/Graphical/ImageResultsList.tsx
ADDED
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { ImageSearchResult } from "../../../../modules/types";
|
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: ImageSearchResult[];
|
17 |
+
}) {
|
18 |
+
const [isLightboxOpen, setLightboxOpen] = useState(false);
|
19 |
+
const [lightboxIndex, setLightboxIndex] = useState(0);
|
20 |
+
const [canStartTransition, setCanStartTransition] = useState(false);
|
21 |
+
|
22 |
+
useEffect(() => {
|
23 |
+
setCanStartTransition(true);
|
24 |
+
}, []);
|
25 |
+
|
26 |
+
const handleImageClick = (index: number) => {
|
27 |
+
setLightboxIndex(index);
|
28 |
+
setLightboxOpen(true);
|
29 |
+
};
|
30 |
+
|
31 |
+
const imageStyle = {
|
32 |
+
objectFit: "cover",
|
33 |
+
height: rem(180),
|
34 |
+
width: rem(240),
|
35 |
+
borderRadius: rem(4),
|
36 |
+
border: `${rem(2)} solid var(--mantine-color-default-border)`,
|
37 |
+
cursor: "zoom-in",
|
38 |
+
} as const;
|
39 |
+
|
40 |
+
return (
|
41 |
+
<>
|
42 |
+
<Carousel slideSize="0" slideGap="xs" align="start" dragFree loop>
|
43 |
+
{imageResults.map(([title, sourceUrl, thumbnailUrl], index) => (
|
44 |
+
<Transition
|
45 |
+
key={`${title}-${sourceUrl}-${thumbnailUrl}`}
|
46 |
+
mounted={canStartTransition}
|
47 |
+
transition="fade"
|
48 |
+
timingFunction="ease"
|
49 |
+
enterDelay={index * 250}
|
50 |
+
duration={1500}
|
51 |
+
>
|
52 |
+
{(styles) => (
|
53 |
+
<Carousel.Slide style={styles}>
|
54 |
+
<img
|
55 |
+
alt={title}
|
56 |
+
src={thumbnailUrl}
|
57 |
+
loading="lazy"
|
58 |
+
onClick={() => handleImageClick(index)}
|
59 |
+
onKeyDown={(e) => {
|
60 |
+
if (e.key === "Enter") {
|
61 |
+
handleImageClick(index);
|
62 |
+
}
|
63 |
+
}}
|
64 |
+
style={imageStyle}
|
65 |
+
/>
|
66 |
+
</Carousel.Slide>
|
67 |
+
)}
|
68 |
+
</Transition>
|
69 |
+
))}
|
70 |
+
</Carousel>
|
71 |
+
<Lightbox
|
72 |
+
open={isLightboxOpen}
|
73 |
+
close={() => setLightboxOpen(false)}
|
74 |
+
plugins={[Captions]}
|
75 |
+
index={lightboxIndex}
|
76 |
+
slides={imageResults.map(([title, url, thumbnailUrl, sourceUrl]) => ({
|
77 |
+
src: thumbnailUrl,
|
78 |
+
alt: title,
|
79 |
+
description: (
|
80 |
+
<Stack align="center" gap="md">
|
81 |
+
{title && (
|
82 |
+
<Text component="cite" ta="center">
|
83 |
+
{title}
|
84 |
+
</Text>
|
85 |
+
)}
|
86 |
+
<Group align="center" justify="center" gap="xs">
|
87 |
+
<Button
|
88 |
+
variant="subtle"
|
89 |
+
component="a"
|
90 |
+
size="xs"
|
91 |
+
href={sourceUrl}
|
92 |
+
target="_blank"
|
93 |
+
title="Click to see the image in full size"
|
94 |
+
rel="noopener noreferrer"
|
95 |
+
onClick={() => {
|
96 |
+
addLogEntry("User visited an image result in full size");
|
97 |
+
}}
|
98 |
+
>
|
99 |
+
View in full resolution
|
100 |
+
</Button>
|
101 |
+
<Button
|
102 |
+
variant="subtle"
|
103 |
+
component="a"
|
104 |
+
href={url}
|
105 |
+
target="_blank"
|
106 |
+
size="xs"
|
107 |
+
title="Click to visit the page where the image was found"
|
108 |
+
rel="noopener noreferrer"
|
109 |
+
onClick={() => {
|
110 |
+
addLogEntry("User visited an image result source");
|
111 |
+
}}
|
112 |
+
>
|
113 |
+
Visit {getHostname(url)}
|
114 |
+
</Button>
|
115 |
+
</Group>
|
116 |
+
</Stack>
|
117 |
+
),
|
118 |
+
}))}
|
119 |
+
/>
|
120 |
+
</>
|
121 |
+
);
|
122 |
+
}
|
client/components/Search/Results/Graphical/ImageResultsLoadingState.tsx
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { AspectRatio, Group, Skeleton } from "@mantine/core";
|
2 |
+
import { em } from "@mantine/core";
|
3 |
+
import { useMediaQuery } from "@mantine/hooks";
|
4 |
+
|
5 |
+
export default function ImageResultsLoadingState() {
|
6 |
+
const hasSmallScreen = useMediaQuery(`(max-width: ${em(530)})`);
|
7 |
+
const numberOfSquareSkeletons = hasSmallScreen ? 4 : 6;
|
8 |
+
const skeletonIds = Array.from(
|
9 |
+
{ length: numberOfSquareSkeletons },
|
10 |
+
(_, i) => `skeleton-${i}`,
|
11 |
+
);
|
12 |
+
|
13 |
+
return (
|
14 |
+
<Group>
|
15 |
+
{skeletonIds.map((id) => (
|
16 |
+
<AspectRatio key={id} ratio={1} flex={1}>
|
17 |
+
<Skeleton />
|
18 |
+
</AspectRatio>
|
19 |
+
))}
|
20 |
+
</Group>
|
21 |
+
);
|
22 |
+
}
|
client/components/Search/Results/Graphical/ImageSearchResults.tsx
ADDED
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Alert } from "@mantine/core";
|
2 |
+
import { IconInfoCircle } from "@tabler/icons-react";
|
3 |
+
import { usePubSub } from "create-pubsub/react";
|
4 |
+
import { Suspense, lazy } from "react";
|
5 |
+
import {
|
6 |
+
imageSearchResultsPubSub,
|
7 |
+
imageSearchStatePubSub,
|
8 |
+
} from "../../../../modules/pubSub";
|
9 |
+
|
10 |
+
const ImageResultsList = lazy(() => import("./ImageResultsList"));
|
11 |
+
const ImageResultsLoadingState = lazy(
|
12 |
+
() => import("./ImageResultsLoadingState"),
|
13 |
+
);
|
14 |
+
|
15 |
+
export default function ImageSearchResults() {
|
16 |
+
const [searchState] = usePubSub(imageSearchStatePubSub);
|
17 |
+
const [results] = usePubSub(imageSearchResultsPubSub);
|
18 |
+
|
19 |
+
if (searchState === "running") {
|
20 |
+
return (
|
21 |
+
<Suspense>
|
22 |
+
<ImageResultsLoadingState />
|
23 |
+
</Suspense>
|
24 |
+
);
|
25 |
+
}
|
26 |
+
|
27 |
+
if (searchState === "completed") {
|
28 |
+
if (results.length > 0) {
|
29 |
+
return (
|
30 |
+
<Suspense>
|
31 |
+
<ImageResultsList imageResults={results} />
|
32 |
+
</Suspense>
|
33 |
+
);
|
34 |
+
}
|
35 |
+
|
36 |
+
return (
|
37 |
+
<Alert
|
38 |
+
variant="light"
|
39 |
+
color="yellow"
|
40 |
+
title="No image results found"
|
41 |
+
icon={<IconInfoCircle />}
|
42 |
+
>
|
43 |
+
Could not find any images matching your search query.
|
44 |
+
</Alert>
|
45 |
+
);
|
46 |
+
}
|
47 |
+
|
48 |
+
if (searchState === "failed") {
|
49 |
+
return (
|
50 |
+
<Alert
|
51 |
+
variant="light"
|
52 |
+
color="yellow"
|
53 |
+
title="Failed to search for images"
|
54 |
+
icon={<IconInfoCircle />}
|
55 |
+
>
|
56 |
+
Could not search for images.
|
57 |
+
</Alert>
|
58 |
+
);
|
59 |
+
}
|
60 |
+
|
61 |
+
return null;
|
62 |
+
}
|
client/components/Search/Results/SearchResultsSection.tsx
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Alert, Loader, Stack, Text } from "@mantine/core";
|
2 |
+
import { usePubSub } from "create-pubsub/react";
|
3 |
+
import { Suspense, lazy } from "react";
|
4 |
+
import { ErrorBoundary } from "react-error-boundary";
|
5 |
+
import { settingsPubSub } from "../../../modules/pubSub";
|
6 |
+
|
7 |
+
const TextSearchResults = lazy(() => import("./Textual/TextSearchResults"));
|
8 |
+
const ImageSearchResults = lazy(() => import("./Graphical/ImageSearchResults"));
|
9 |
+
|
10 |
+
const ErrorFallback = ({ error }: { error: Error }) => (
|
11 |
+
<Alert color="red" title="Error loading search results">
|
12 |
+
<Text size="sm">{error.message}</Text>
|
13 |
+
<Text size="xs" c="dimmed">
|
14 |
+
Please try again later or contact support if the issue persists.
|
15 |
+
</Text>
|
16 |
+
</Alert>
|
17 |
+
);
|
18 |
+
|
19 |
+
const LoadingFallback = () => (
|
20 |
+
<Stack align="center" p="md">
|
21 |
+
<Loader size="sm" />
|
22 |
+
<Text size="sm" c="dimmed">
|
23 |
+
Loading component, please wait...
|
24 |
+
</Text>
|
25 |
+
</Stack>
|
26 |
+
);
|
27 |
+
|
28 |
+
export default function SearchResultsSection() {
|
29 |
+
const [settings] = usePubSub(settingsPubSub);
|
30 |
+
|
31 |
+
const renderSearchResults = (
|
32 |
+
Component: React.LazyExoticComponent<React.ComponentType>,
|
33 |
+
enabled: boolean,
|
34 |
+
) =>
|
35 |
+
enabled && (
|
36 |
+
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
37 |
+
<Suspense fallback={<LoadingFallback />}>
|
38 |
+
<Component />
|
39 |
+
</Suspense>
|
40 |
+
</ErrorBoundary>
|
41 |
+
);
|
42 |
+
|
43 |
+
return (
|
44 |
+
<Stack gap="xl">
|
45 |
+
{renderSearchResults(ImageSearchResults, settings.enableImageSearch)}
|
46 |
+
{renderSearchResults(TextSearchResults, settings.enableTextSearch)}
|
47 |
+
</Stack>
|
48 |
+
);
|
49 |
+
}
|
client/components/Search/Results/Textual/SearchResultsList.tsx
ADDED
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { getHostname } from "../../../../modules/stringFormatters";
|
14 |
+
import type { TextSearchResult } from "../../../../modules/types";
|
15 |
+
|
16 |
+
export default function SearchResultsList({
|
17 |
+
searchResults,
|
18 |
+
}: {
|
19 |
+
searchResults: TextSearchResult[];
|
20 |
+
}) {
|
21 |
+
const shouldDisplayDomainBelowTitle = useMediaQuery(
|
22 |
+
`(max-width: ${em(720)})`,
|
23 |
+
);
|
24 |
+
const [canStartTransition, setCanStartTransition] = useState(false);
|
25 |
+
|
26 |
+
useEffect(() => {
|
27 |
+
setCanStartTransition(true);
|
28 |
+
}, []);
|
29 |
+
|
30 |
+
return (
|
31 |
+
<Stack gap={40}>
|
32 |
+
{searchResults.map(([title, snippet, url], index) => (
|
33 |
+
<Transition
|
34 |
+
key={url}
|
35 |
+
mounted={canStartTransition}
|
36 |
+
transition="fade"
|
37 |
+
timingFunction="ease"
|
38 |
+
enterDelay={index * 200}
|
39 |
+
duration={750}
|
40 |
+
>
|
41 |
+
{(styles) => (
|
42 |
+
<Stack gap={16} style={styles}>
|
43 |
+
<Flex
|
44 |
+
gap={shouldDisplayDomainBelowTitle ? 0 : 16}
|
45 |
+
justify="space-between"
|
46 |
+
align="flex-start"
|
47 |
+
direction={shouldDisplayDomainBelowTitle ? "column" : "row"}
|
48 |
+
>
|
49 |
+
<UnstyledButton
|
50 |
+
variant="transparent"
|
51 |
+
component="a"
|
52 |
+
href={url}
|
53 |
+
target="_blank"
|
54 |
+
onClick={() => {
|
55 |
+
addLogEntry("User clicked a text result");
|
56 |
+
}}
|
57 |
+
>
|
58 |
+
<Text fw="bold" c="var(--mantine-color-blue-light-color)">
|
59 |
+
{title}
|
60 |
+
</Text>
|
61 |
+
</UnstyledButton>
|
62 |
+
<Tooltip label={url}>
|
63 |
+
<UnstyledButton
|
64 |
+
variant="transparent"
|
65 |
+
component="a"
|
66 |
+
href={url}
|
67 |
+
target="_blank"
|
68 |
+
fs="italic"
|
69 |
+
ta="end"
|
70 |
+
onClick={() => {
|
71 |
+
addLogEntry("User clicked a text result");
|
72 |
+
}}
|
73 |
+
>
|
74 |
+
{getHostname(url)}
|
75 |
+
</UnstyledButton>
|
76 |
+
</Tooltip>
|
77 |
+
</Flex>
|
78 |
+
<Text size="sm" c="dimmed" style={{ wordWrap: "break-word" }}>
|
79 |
+
{snippet}
|
80 |
+
</Text>
|
81 |
+
</Stack>
|
82 |
+
)}
|
83 |
+
</Transition>
|
84 |
+
))}
|
85 |
+
</Stack>
|
86 |
+
);
|
87 |
+
}
|
client/components/Search/Results/Textual/TextResultsLoadingState.tsx
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Skeleton, Stack } from "@mantine/core";
|
2 |
+
|
3 |
+
export default function TextResultsLoadingState() {
|
4 |
+
return (
|
5 |
+
<Stack>
|
6 |
+
<Stack>
|
7 |
+
<Skeleton height={8} radius="xl" />
|
8 |
+
<Skeleton height={8} width="87%" radius="xl" />
|
9 |
+
<Skeleton height={8} radius="xl" />
|
10 |
+
<Skeleton height={8} width="70%" radius="xl" />
|
11 |
+
<Skeleton height={8} radius="xl" />
|
12 |
+
<Skeleton height={8} width="52%" radius="xl" />
|
13 |
+
<Skeleton height={8} radius="xl" />
|
14 |
+
<Skeleton height={8} width="63%" radius="xl" />
|
15 |
+
</Stack>
|
16 |
+
</Stack>
|
17 |
+
);
|
18 |
+
}
|
client/components/Search/Results/Textual/TextSearchResults.tsx
ADDED
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Alert } from "@mantine/core";
|
2 |
+
import { IconInfoCircle } from "@tabler/icons-react";
|
3 |
+
import { usePubSub } from "create-pubsub/react";
|
4 |
+
import { Suspense, lazy } from "react";
|
5 |
+
import {
|
6 |
+
textSearchResultsPubSub,
|
7 |
+
textSearchStatePubSub,
|
8 |
+
} from "../../../../modules/pubSub";
|
9 |
+
|
10 |
+
const SearchResultsList = lazy(() => import("./SearchResultsList"));
|
11 |
+
const TextResultsLoadingState = lazy(() => import("./TextResultsLoadingState"));
|
12 |
+
|
13 |
+
export default function TextSearchResults() {
|
14 |
+
const [searchState] = usePubSub(textSearchStatePubSub);
|
15 |
+
const [results] = usePubSub(textSearchResultsPubSub);
|
16 |
+
|
17 |
+
if (searchState === "running") {
|
18 |
+
return (
|
19 |
+
<Suspense>
|
20 |
+
<TextResultsLoadingState />
|
21 |
+
</Suspense>
|
22 |
+
);
|
23 |
+
}
|
24 |
+
|
25 |
+
if (searchState === "completed") {
|
26 |
+
if (results.length > 0) {
|
27 |
+
return (
|
28 |
+
<Suspense>
|
29 |
+
<SearchResultsList searchResults={results} />
|
30 |
+
</Suspense>
|
31 |
+
);
|
32 |
+
}
|
33 |
+
|
34 |
+
return (
|
35 |
+
<Alert
|
36 |
+
variant="light"
|
37 |
+
color="yellow"
|
38 |
+
title="No results found"
|
39 |
+
icon={<IconInfoCircle />}
|
40 |
+
>
|
41 |
+
No text results found for your search query.
|
42 |
+
</Alert>
|
43 |
+
);
|
44 |
+
}
|
45 |
+
|
46 |
+
if (searchState === "failed") {
|
47 |
+
return (
|
48 |
+
<Alert
|
49 |
+
variant="light"
|
50 |
+
color="red"
|
51 |
+
title="Search failed"
|
52 |
+
icon={<IconInfoCircle />}
|
53 |
+
>
|
54 |
+
Failed to fetch text results. Please try again.
|
55 |
+
</Alert>
|
56 |
+
);
|
57 |
+
}
|
58 |
+
|
59 |
+
return null;
|
60 |
+
}
|
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/keyboard.ts
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { KeyboardEvent } from "react";
|
2 |
+
|
3 |
+
export const handleEnterKeyDown = (
|
4 |
+
event: KeyboardEvent<HTMLTextAreaElement>,
|
5 |
+
settings: { enterToSubmit: boolean },
|
6 |
+
onSubmit: () => void,
|
7 |
+
) => {
|
8 |
+
if (
|
9 |
+
(event.code === "Enter" && !event.shiftKey && settings.enterToSubmit) ||
|
10 |
+
(event.code === "Enter" && event.shiftKey && !settings.enterToSubmit)
|
11 |
+
) {
|
12 |
+
event.preventDefault();
|
13 |
+
onSubmit();
|
14 |
+
}
|
15 |
+
};
|
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 |
+
}
|