github-actions[bot] commited on
Commit
f31a721
·
0 Parent(s):

Sync from https://github.com/felladrin/MiniSearch

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +25 -0
  2. .editorconfig +7 -0
  3. .env.example +33 -0
  4. .github/hf-space-config.yml +11 -0
  5. .github/workflows/ai-review.yml +138 -0
  6. .github/workflows/ci.yml +33 -0
  7. .github/workflows/deploy-to-hugging-face.yml +18 -0
  8. .github/workflows/on-pull-request-to-main.yml +9 -0
  9. .github/workflows/on-push-to-main.yml +7 -0
  10. .github/workflows/publish-docker-image.yml +39 -0
  11. .github/workflows/reusable-test-lint-ping.yml +25 -0
  12. .gitignore +9 -0
  13. .husky/pre-commit +1 -0
  14. .npmrc +1 -0
  15. Dockerfile +96 -0
  16. README.md +127 -0
  17. agents.md +167 -0
  18. biome.json +34 -0
  19. client/components/AiResponse/AiModelDownloadAllowanceContent.tsx +62 -0
  20. client/components/AiResponse/AiResponseContent.tsx +219 -0
  21. client/components/AiResponse/AiResponseSection.tsx +101 -0
  22. client/components/AiResponse/ChatHeader.tsx +33 -0
  23. client/components/AiResponse/ChatInputArea.tsx +106 -0
  24. client/components/AiResponse/ChatInterface.tsx +436 -0
  25. client/components/AiResponse/CopyIconButton.tsx +32 -0
  26. client/components/AiResponse/EnableAiResponsePrompt.tsx +85 -0
  27. client/components/AiResponse/ExpandableLink.tsx +123 -0
  28. client/components/AiResponse/FormattedMarkdown.tsx +41 -0
  29. client/components/AiResponse/LoadingModelContent.tsx +40 -0
  30. client/components/AiResponse/MarkdownRenderer.tsx +104 -0
  31. client/components/AiResponse/MessageList.tsx +128 -0
  32. client/components/AiResponse/PreparingContent.tsx +33 -0
  33. client/components/AiResponse/ReasoningSection.tsx +79 -0
  34. client/components/AiResponse/WebLlmModelSelect.tsx +81 -0
  35. client/components/AiResponse/WllamaModelSelect.tsx +42 -0
  36. client/components/AiResponse/hooks/useReasoningContent.test.ts +96 -0
  37. client/components/AiResponse/hooks/useReasoningContent.ts +69 -0
  38. client/components/Analytics/SearchStats.tsx +313 -0
  39. client/components/App/App.tsx +123 -0
  40. client/components/Logs/LogsModal.tsx +136 -0
  41. client/components/Logs/ShowLogsButton.tsx +42 -0
  42. client/components/Pages/AccessPage.tsx +70 -0
  43. client/components/Pages/Main/MainPage.test.tsx +110 -0
  44. client/components/Pages/Main/MainPage.tsx +81 -0
  45. client/components/Pages/Main/Menu/AISettings/AISettingsForm.tsx +205 -0
  46. client/components/Pages/Main/Menu/AISettings/components/AIParameterSlider.tsx +24 -0
  47. client/components/Pages/Main/Menu/AISettings/components/BrowserSettings.tsx +69 -0
  48. client/components/Pages/Main/Menu/AISettings/components/HordeSettings.tsx +54 -0
  49. client/components/Pages/Main/Menu/AISettings/components/OpenAISettings.tsx +76 -0
  50. client/components/Pages/Main/Menu/AISettings/components/SystemPromptInput.tsx +113 -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="Qwen3-0.6B-q4f16_1-MLC"
9
+
10
+ # The default model ID for WebLLM with F32 shaders.
11
+ WEBLLM_DEFAULT_F32_MODEL_ID="Qwen3-0.6B-q4f32_1-MLC"
12
+
13
+ # The default model ID for Wllama.
14
+ WLLAMA_DEFAULT_MODEL_ID="qwen-3-0.6b"
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/hf-space-config.yml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ title: MiniSearch
2
+ emoji: 👌🔍
3
+ colorFrom: yellow
4
+ colorTo: yellow
5
+ sdk: docker
6
+ short_description: Minimalist web-searching app with browser-based AI assistant
7
+ pinned: true
8
+ custom_headers:
9
+ cross-origin-embedder-policy: require-corp
10
+ cross-origin-opener-policy: same-origin
11
+ cross-origin-resource-policy: cross-origin
.github/workflows/ai-review.yml ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Review Pull Request with AI
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
+ ai-review:
14
+ if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-ai-review') }}
15
+ continue-on-error: true
16
+ runs-on: ubuntu-latest
17
+ name: AI Review
18
+ permissions:
19
+ pull-requests: write
20
+ contents: read
21
+ timeout-minutes: 30
22
+ steps:
23
+ - name: Checkout Repository
24
+ uses: actions/checkout@v6
25
+
26
+ - name: Create temporary directory
27
+ run: mkdir -p /tmp/pr_review
28
+
29
+ - name: Process PR description
30
+ id: process_pr
31
+ run: |
32
+ PR_BODY_ESCAPED=$(cat << 'EOF'
33
+ ${{ github.event.pull_request.body }}
34
+ EOF
35
+ )
36
+ PROCESSED_BODY=$(echo "$PR_BODY_ESCAPED" | sed -E 's/\[(.*?)\]\(.*?\)/\1/g')
37
+ echo "$PROCESSED_BODY" > /tmp/pr_review/processed_body.txt
38
+
39
+ - name: Fetch branches and output the diff
40
+ run: |
41
+ git fetch origin main:main
42
+ git fetch origin pull/${{ github.event.pull_request.number }}/head:pr-branch
43
+ git diff main..pr-branch > /tmp/pr_review/diff.txt
44
+
45
+ - name: Prepare review request
46
+ id: prepare_request
47
+ run: |
48
+ PR_TITLE=$(echo "${{ github.event.pull_request.title }}" | sed 's/[()]/\\&/g')
49
+ DIFF_CONTENT=$(cat /tmp/pr_review/diff.txt)
50
+ PROCESSED_BODY=$(cat /tmp/pr_review/processed_body.txt)
51
+
52
+ jq -n \
53
+ --arg model "${{ vars.OPENAI_COMPATIBLE_API_MODEL }}" \
54
+ --arg http_referer "${{ github.event.repository.html_url }}" \
55
+ --arg title "${{ github.event.repository.name }}" \
56
+ --arg system "You are an experienced developer reviewing a Pull Request. You focus only on what matters and provide concise, actionable feedback.
57
+
58
+ Review Context:
59
+ Repository Name: \"${{ github.event.repository.name }}\"
60
+ Repository Description: \"${{ github.event.repository.description }}\"
61
+ Branch: \"${{ github.event.pull_request.head.ref }}\"
62
+ PR Title: \"$PR_TITLE\"
63
+
64
+ Guidelines:
65
+ 1. Only comment on issues that:
66
+ - Could cause bugs or security issues
67
+ - Significantly impact performance
68
+ - Make the code harder to maintain
69
+ - Violate critical best practices
70
+
71
+ 2. For each issue:
72
+ - Point to the specific line/file
73
+ - Explain why it's a problem
74
+ - Suggest a concrete fix
75
+
76
+ 3. Praise exceptional solutions briefly, only if truly innovative
77
+
78
+ 4. Skip commenting on:
79
+ - Minor style issues
80
+ - Obvious changes
81
+ - Working code that could be marginally improved
82
+ - Things that are just personal preference
83
+
84
+ Remember:
85
+ Less is more. If the code is good and working, just say so, with a short message." \
86
+ --arg user "This is the description of the pull request:
87
+ \`\`\`markdown
88
+ $PROCESSED_BODY
89
+ \`\`\`
90
+
91
+ And here is the diff of the changes, for you to review:
92
+ \`\`\`diff
93
+ $DIFF_CONTENT
94
+ \`\`\`" \
95
+ '{
96
+ "model": $model,
97
+ "messages": [
98
+ {"role": "system", "content": $system},
99
+ {"role": "user", "content": $user}
100
+ ],
101
+ "temperature": 0.7,
102
+ "top_p": 0.9
103
+ }' > /tmp/pr_review/request.json
104
+ - name: Get AI Review
105
+ id: ai_review
106
+ run: |
107
+ RESPONSE=$(curl -s ${{ vars.OPENAI_COMPATIBLE_API_BASE_URL }}/chat/completions \
108
+ -H "Content-Type: application/json" \
109
+ -H "Authorization: Bearer ${{ secrets.OPENAI_COMPATIBLE_API_KEY }}" \
110
+ -d @/tmp/pr_review/request.json)
111
+
112
+ # Check for errors in the response
113
+ if echo "$RESPONSE" | jq -e '.object == "error"' > /dev/null; then
114
+ echo "Error from API:" >&2
115
+ ERROR_MSG=$(echo "$RESPONSE" | jq -r '.message.detail[0].msg // .message')
116
+ echo "$ERROR_MSG" >&2
117
+ exit 1
118
+ fi
119
+
120
+ echo "### Review" > /tmp/pr_review/response.txt
121
+ echo "" >> /tmp/pr_review/response.txt
122
+ echo "$RESPONSE" | jq -r '.choices[0].message.content' >> /tmp/pr_review/response.txt
123
+
124
+ - name: Find Comment
125
+ uses: peter-evans/find-comment@v4
126
+ id: find_comment
127
+ with:
128
+ issue-number: ${{ github.event.pull_request.number }}
129
+ comment-author: "github-actions[bot]"
130
+ body-includes: "### Review"
131
+
132
+ - name: Post or Update PR Review
133
+ uses: peter-evans/create-or-update-comment@v5
134
+ with:
135
+ comment-id: ${{ steps.find_comment.outputs.comment-id }}
136
+ issue-number: ${{ github.event.pull_request.number }}
137
+ body-path: /tmp/pr_review/response.txt
138
+ edit-mode: replace
.github/workflows/ci.yml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master]
6
+ pull_request:
7
+ branches: [main, master]
8
+
9
+ jobs:
10
+ build-test:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - name: Checkout repository
15
+ uses: actions/checkout@v6
16
+
17
+ - name: Set up Node.js
18
+ uses: actions/setup-node@v6
19
+ with:
20
+ node-version: lts/*
21
+ cache: npm
22
+
23
+ - name: Install dependencies
24
+ run: npm ci
25
+
26
+ - name: Run lint
27
+ run: npm run lint
28
+
29
+ - name: Check formatting
30
+ run: npm run format
31
+
32
+ - name: Run tests
33
+ run: npm test
.github/workflows/deploy-to-hugging-face.yml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to Hugging Face
2
+
3
+ on:
4
+ workflow_dispatch:
5
+
6
+ jobs:
7
+ sync-to-hf:
8
+ name: Sync to Hugging Face Spaces
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v6
12
+ - uses: JacobLinCool/huggingface-sync@v1
13
+ with:
14
+ github: ${{ secrets.GITHUB_TOKEN }}
15
+ user: ${{ vars.HF_SPACE_OWNER }}
16
+ space: ${{ vars.HF_SPACE_NAME }}
17
+ token: ${{ secrets.HF_TOKEN }}
18
+ configuration: ".github/hf-space-config.yml"
.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/publish-docker-image.yml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish Docker Image
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@v6
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
.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@v6
9
+ - uses: actions/setup-node@v6
10
+ with:
11
+ node-version: "lts/*"
12
+ cache: "npm"
13
+ - run: npm ci --ignore-scripts
14
+ - run: npm run lint
15
+ check-docker-container:
16
+ needs: [check-code-quality]
17
+ name: Check Docker Container
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v6
21
+ - run: docker compose -f docker-compose.production.yml up -d
22
+ - name: Check if main page is available
23
+ run: until curl -s -o /dev/null -w "%{http_code}" localhost:7860 | grep 200; do sleep 1; done
24
+ timeout-minutes: 1
25
+ - run: docker compose -f docker-compose.production.yml down
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .DS_Store
3
+ /client/dist
4
+ /server/models
5
+ .vscode
6
+ /vite-build-stats.html
7
+ .env
8
+ /coverage
9
+ .playwright-cli
.husky/pre-commit ADDED
@@ -0,0 +1 @@
 
 
1
+ npm install --no-save @biomejs/biome && npx @biomejs/biome check --write --staged --no-errors-on-unmatched
.npmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ legacy-peer-deps = true
Dockerfile ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:lts AS llama-builder
2
+
3
+ ARG LLAMA_CPP_RELEASE_TAG="b6604"
4
+
5
+ RUN apt-get update && apt-get install -y \
6
+ build-essential \
7
+ cmake \
8
+ ccache \
9
+ git \
10
+ curl
11
+
12
+ RUN cd /tmp && \
13
+ git clone https://github.com/ggerganov/llama.cpp.git && \
14
+ cd llama.cpp && \
15
+ git checkout $LLAMA_CPP_RELEASE_TAG && \
16
+ cmake -B build -DGGML_NATIVE=OFF -DLLAMA_CURL=OFF && \
17
+ cmake --build build --config Release -j --target llama-server && \
18
+ mkdir -p /usr/local/lib/llama && \
19
+ find build -type f \( -name "libllama.so" -o -name "libmtmd.so" -o -name "libggml.so" -o -name "libggml-base.so" -o -name "libggml-cpu.so" \) -exec cp {} /usr/local/lib/llama/ \;
20
+
21
+ FROM node:lts
22
+
23
+ ENV PORT=7860
24
+ EXPOSE $PORT
25
+
26
+ ARG USERNAME=node
27
+ ARG HOME_DIR=/home/${USERNAME}
28
+ ARG APP_DIR=${HOME_DIR}/app
29
+
30
+ RUN apt-get update && \
31
+ apt-get install -y --no-install-recommends \
32
+ python3 \
33
+ python3-venv && \
34
+ apt-get clean && \
35
+ rm -rf /var/lib/apt/lists/*
36
+
37
+ RUN mkdir -p /usr/local/searxng /etc/searxng && \
38
+ chown -R ${USERNAME}:${USERNAME} /usr/local/searxng /etc/searxng && \
39
+ chmod 755 /etc/searxng
40
+
41
+ WORKDIR /usr/local/searxng
42
+ RUN python3 -m venv searxng-venv && \
43
+ chown -R ${USERNAME}:${USERNAME} /usr/local/searxng/searxng-venv && \
44
+ /usr/local/searxng/searxng-venv/bin/pip install --upgrade pip && \
45
+ /usr/local/searxng/searxng-venv/bin/pip install wheel setuptools pyyaml lxml
46
+
47
+ RUN git clone https://github.com/searxng/searxng.git /usr/local/searxng/searxng-src && \
48
+ chown -R ${USERNAME}:${USERNAME} /usr/local/searxng/searxng-src
49
+
50
+ ARG SEARXNG_SETTINGS_PATH="/etc/searxng/settings.yml"
51
+
52
+ WORKDIR /usr/local/searxng/searxng-src
53
+ RUN cp searx/settings.yml $SEARXNG_SETTINGS_PATH && \
54
+ chown ${USERNAME}:${USERNAME} $SEARXNG_SETTINGS_PATH && \
55
+ chmod 644 $SEARXNG_SETTINGS_PATH && \
56
+ sed -i 's/ultrasecretkey/'$(openssl rand -hex 32)'/g' $SEARXNG_SETTINGS_PATH && \
57
+ sed -i 's/- html/- json/' $SEARXNG_SETTINGS_PATH && \
58
+ /usr/local/searxng/searxng-venv/bin/pip install -r requirements.txt && \
59
+ /usr/local/searxng/searxng-venv/bin/pip install --no-build-isolation -e .
60
+
61
+ COPY --from=llama-builder /tmp/llama.cpp/build/bin/llama-server /usr/local/bin/
62
+ COPY --from=llama-builder /usr/local/lib/llama/* /usr/local/lib/
63
+ RUN ldconfig /usr/local/lib
64
+
65
+ USER ${USERNAME}
66
+
67
+ WORKDIR ${APP_DIR}
68
+
69
+ ARG ACCESS_KEYS
70
+ ARG ACCESS_KEY_TIMEOUT_HOURS
71
+ ARG WEBLLM_DEFAULT_F16_MODEL_ID
72
+ ARG WEBLLM_DEFAULT_F32_MODEL_ID
73
+ ARG WLLAMA_DEFAULT_MODEL_ID
74
+ ARG INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL
75
+ ARG INTERNAL_OPENAI_COMPATIBLE_API_KEY
76
+ ARG INTERNAL_OPENAI_COMPATIBLE_API_MODEL
77
+ ARG INTERNAL_OPENAI_COMPATIBLE_API_NAME
78
+ ARG DEFAULT_INFERENCE_TYPE
79
+ ARG HOST
80
+ ARG HMR_PORT
81
+ ARG ALLOWED_HOSTS
82
+
83
+ COPY --chown=${USERNAME}:${USERNAME} ./package.json ./package-lock.json ./.npmrc ./
84
+
85
+ RUN npm ci
86
+
87
+ COPY --chown=${USERNAME}:${USERNAME} . .
88
+
89
+ RUN git config --global --add safe.directory ${APP_DIR} && \
90
+ npm run build
91
+
92
+ HEALTHCHECK --interval=5m CMD curl -f http://localhost:7860/status || exit 1
93
+
94
+ ENTRYPOINT [ "/bin/sh", "-c" ]
95
+
96
+ CMD ["(cd /usr/local/searxng/searxng-src && /usr/local/searxng/searxng-venv/bin/python -m searx.webapp > /dev/null 2>&1) & npm start -- --host"]
README.md ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/felladrin/MiniSearch)
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 typing 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>
agents.md ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MiniSearch Agent Guidelines
2
+
3
+ This is your navigation hub. Start here, follow the links, and return when you need orientation.
4
+
5
+ ## Before You Start
6
+
7
+ **New to this codebase?** Read in this order:
8
+ 1. `docs/quick-start.md` - Get it running
9
+ 2. `docs/overview.md` - Understand the system
10
+ 3. `docs/project-structure.md` - Navigate the code
11
+
12
+ **Making changes?** Check:
13
+ - `docs/coding-conventions.md` - Code style
14
+ - `docs/development-commands.md` - Available commands
15
+ - `docs/pull-requests.md` - How to submit
16
+
17
+ ## Repository Map
18
+
19
+ ### Getting Started
20
+ - **`docs/quick-start.md`** - Installation, first run, verification
21
+ - **`docs/overview.md`** - System architecture and data flow
22
+ - **`docs/project-structure.md`** - Directory layout and component organization
23
+
24
+ ### Configuration & Setup
25
+ - **`docs/configuration.md`** - Environment variables and settings reference
26
+ - **`docs/security.md`** - Access control, privacy, and security model
27
+
28
+ ### Core Functionality
29
+ - **`docs/ai-integration.md`** - AI inference types (WebLLM, Wllama, OpenAI, AI Horde, Internal)
30
+ - **`docs/ui-components.md`** - Component architecture and PubSub patterns
31
+ - **`docs/search-history.md`** - History database schema and management
32
+ - **`docs/conversation-memory.md`** - Token budgeting and rolling summaries
33
+
34
+ ### Development
35
+ - **`docs/development-commands.md`** - Docker, npm, and testing commands
36
+ - **`docs/coding-conventions.md`** - Style guide and patterns
37
+ - **`docs/pull-requests.md`** - PR process and merge philosophy
38
+ - **`docs/core-technologies.md`** - Technology stack and dependencies
39
+ - **`docs/design.md`** - UI/UX design principles
40
+
41
+ ## Agent Decision Tree
42
+
43
+ ```
44
+ Need to:
45
+ ├── Add a feature?
46
+ │ ├── UI component → docs/ui-components.md
47
+ │ ├── AI integration → docs/ai-integration.md
48
+ │ ├── Search functionality → client/modules/search.ts
49
+ │ └── Settings option → docs/configuration.md
50
+ ├── Fix a bug?
51
+ │ ├── UI issue → Check component + PubSub channels
52
+ │ ├── AI not working → docs/ai-integration.md + browser console
53
+ │ ├── Search failing → Check SearXNG + server hooks
54
+ │ └── Build error → docs/development-commands.md
55
+ ├── Configure deployment?
56
+ │ ├── Environment variables → docs/configuration.md
57
+ │ ├── Access control → docs/security.md
58
+ │ └── Docker setup → docs/overview.md
59
+ └── Understand data flow?
60
+ ├── Search flow → client/modules/search.ts
61
+ ├── AI generation → client/modules/textGeneration.ts
62
+ ├── State management → docs/ui-components.md
63
+ └── History/Chat → docs/search-history.md + docs/conversation-memory.md
64
+ ```
65
+
66
+ ## Key Files Reference
67
+
68
+ ### Entry Points
69
+ - `client/main.tsx` - React app initialization
70
+ - `vite.config.ts` - Vite dev server with hooks
71
+ - `Dockerfile` - Multi-stage container build
72
+
73
+ ### Core Modules (Business Logic)
74
+ - `client/modules/search.ts` - Search orchestration and caching
75
+ - `client/modules/textGeneration.ts` - AI response flow
76
+ - `client/modules/pubSub.ts` - All PubSub channels
77
+ - `client/modules/settings.ts` - Settings management
78
+ - `client/modules/history.ts` - Search history database
79
+
80
+ ### Server-Side
81
+ - `server/searchEndpointServerHook.ts` - `/search` endpoints
82
+ - `server/internalApiEndpointServerHook.ts` - `/inference` proxy
83
+ - `server/webSearchService.ts` - SearXNG integration
84
+ - `server/rerankerService.ts` - Local result reranking
85
+
86
+ ### Components (Key)
87
+ - `client/components/App/` - Application shell
88
+ - `client/components/Search/Form/` - Search input
89
+ - `client/components/Search/Results/` - Results display
90
+ - `client/components/AiResponse/` - AI response + chat
91
+ - `client/components/Pages/Main/Menu/` - Settings drawers
92
+
93
+ ## Common Tasks Quick Reference
94
+
95
+ ### Add a new AI model
96
+ 1. Add to `client/modules/wllama.ts` or WebLLM registry
97
+ 2. Update `docs/ai-integration.md`
98
+ 3. Update `docs/configuration.md` defaults
99
+
100
+ ### Add a new setting
101
+ 1. Add to `client/modules/settings.ts` default object
102
+ 2. Add UI in `client/components/Pages/Main/Menu/`
103
+ 3. Update `docs/configuration.md` settings table
104
+
105
+ ### Modify search behavior
106
+ 1. Edit `client/modules/search.ts`
107
+ 2. Update `server/webSearchService.ts` if server-side changes needed
108
+ 3. Check `server/rerankerService.ts` if reranking affected
109
+
110
+ ### Fix UI state issues
111
+ 1. Check PubSub channels in `client/modules/pubSub.ts`
112
+ 2. Verify component subscriptions in `docs/ui-components.md`
113
+ 3. Ensure proper state updates in business logic modules
114
+
115
+ ### Analyze test coverage
116
+ 1. Run `npm run test:coverage` to generate reports
117
+ 2. Check `coverage/coverage-summary.json` for quick metrics
118
+ 3. See `docs/development-commands.md` for full coverage analysis guide
119
+
120
+ ## Quality Gates
121
+
122
+ Before any change:
123
+ ```bash
124
+ docker compose exec development-server npm run lint
125
+ ```
126
+
127
+ This runs:
128
+ - Biome (formatting/linting)
129
+ - TypeScript (type checking)
130
+ - ts-prune (dead code detection)
131
+ - jscpd (copy-paste detection)
132
+ - dpdm (circular dependency detection)
133
+ - Custom architectural linter
134
+
135
+ ## Agent-First Principles
136
+
137
+ **Repository as System of Record:**
138
+ - All knowledge lives in versioned docs/ structure
139
+ - This file is your entry point - start here
140
+ - Follow links, don't assume - verify in code
141
+
142
+ **Context Efficiency:**
143
+ - Use this map to navigate quickly
144
+ - Return to this file when context drifts
145
+ - Follow the decision tree for common tasks
146
+
147
+ **Architecture & Boundaries:**
148
+ - Respect PubSub boundaries - don't cross concerns
149
+ - Client vs server - keep them separate
150
+ - Feature-based organization - one folder per feature
151
+
152
+ **Documentation Maintenance:**
153
+ - Update these docs when you learn something new
154
+ - Add cross-references when linking concepts
155
+ - Keep examples current with actual code
156
+
157
+ ## Technology Stack
158
+
159
+ React + TypeScript + Mantine UI v8, with privacy-first architecture.
160
+ See `docs/core-technologies.md` for complete dependency list and selection criteria.
161
+
162
+ ## Need Help?
163
+
164
+ 1. Check relevant doc in `docs/`
165
+ 2. Read the module code in `client/modules/` or `server/`
166
+ 3. Look at similar existing implementations
167
+ 4. Run `npm run lint` to validate changes
biome.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/latest/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "ignoreUnknown": false
10
+ },
11
+ "formatter": {
12
+ "enabled": true,
13
+ "indentStyle": "space"
14
+ },
15
+ "linter": {
16
+ "enabled": true,
17
+ "rules": {
18
+ "recommended": true
19
+ }
20
+ },
21
+ "javascript": {
22
+ "formatter": {
23
+ "quoteStyle": "double"
24
+ }
25
+ },
26
+ "assist": {
27
+ "enabled": true,
28
+ "actions": {
29
+ "source": {
30
+ "organizeImports": "on"
31
+ }
32
+ }
33
+ }
34
+ }
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,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, useMemo, useState } from "react";
23
+ import { addLogEntry } from "@/modules/logEntries";
24
+ import { settingsPubSub } from "@/modules/pubSub";
25
+ import { searchAndRespond } from "@/modules/textGeneration";
26
+ import CopyIconButton from "./CopyIconButton";
27
+ import FormattedMarkdown from "./FormattedMarkdown";
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
55
+ mah={300}
56
+ type="auto"
57
+ scrollbars="y"
58
+ offsetScrollbars
59
+ >
60
+ {children}
61
+ </ScrollArea.Autosize>
62
+ ) : (
63
+ <Box>{children}</Box>
64
+ );
65
+ },
66
+ [settings.enableAiResponseScrolling],
67
+ );
68
+
69
+ function speakResponse(text: string) {
70
+ if (isSpeaking) {
71
+ self.speechSynthesis.cancel();
72
+ setIsSpeaking(false);
73
+ return;
74
+ }
75
+
76
+ const prepareTextForSpeech = (textToClean: string) => {
77
+ const withoutReasoning = textToClean.replace(
78
+ new RegExp(
79
+ `${settings.reasoningStartMarker}[\\s\\S]*?${settings.reasoningEndMarker}`,
80
+ "g",
81
+ ),
82
+ "",
83
+ );
84
+ const withoutLinks = withoutReasoning.replace(
85
+ /\[([^\]]+)\]\([^)]+\)/g,
86
+ "($1)",
87
+ );
88
+ const withoutMarkdown = withoutLinks.replace(/[#*`_~[\]]/g, "");
89
+ return withoutMarkdown.trim();
90
+ };
91
+
92
+ const utterance = new SpeechSynthesisUtterance(prepareTextForSpeech(text));
93
+
94
+ const voices = self.speechSynthesis.getVoices();
95
+
96
+ if (voices.length > 0 && settings.selectedVoiceId) {
97
+ const voice = voices.find(
98
+ (voice) => voice.voiceURI === settings.selectedVoiceId,
99
+ );
100
+
101
+ if (voice) {
102
+ utterance.voice = voice;
103
+ utterance.lang = voice.lang;
104
+ }
105
+ }
106
+
107
+ utterance.onerror = () => {
108
+ addLogEntry("Failed to speak response");
109
+ setIsSpeaking(false);
110
+ };
111
+
112
+ utterance.onend = () => setIsSpeaking(false);
113
+
114
+ setIsSpeaking(true);
115
+ self.speechSynthesis.speak(utterance);
116
+ }
117
+
118
+ return (
119
+ <Card withBorder shadow="sm" radius="md">
120
+ <Card.Section withBorder inheritPadding py="xs">
121
+ <Group justify="space-between">
122
+ <Group gap="xs" align="center">
123
+ <Text fw={500}>
124
+ {textGenerationState === "generating"
125
+ ? "Generating AI Response..."
126
+ : "AI Response"}
127
+ </Text>
128
+ {textGenerationState === "interrupted" && (
129
+ <Badge variant="light" color="yellow" size="xs">
130
+ Interrupted
131
+ </Badge>
132
+ )}
133
+ </Group>
134
+ <Group gap="xs" align="center">
135
+ {textGenerationState === "generating" ? (
136
+ <Tooltip label="Interrupt generation">
137
+ <ActionIcon
138
+ onClick={() => setTextGenerationState("interrupted")}
139
+ variant="subtle"
140
+ color="gray"
141
+ >
142
+ <IconHandStop size={16} />
143
+ </ActionIcon>
144
+ </Tooltip>
145
+ ) : (
146
+ <Tooltip label="Regenerate response">
147
+ <ActionIcon
148
+ onClick={() => searchAndRespond()}
149
+ variant="subtle"
150
+ color="gray"
151
+ >
152
+ <IconRefresh size={16} />
153
+ </ActionIcon>
154
+ </Tooltip>
155
+ )}
156
+ <Tooltip
157
+ label={isSpeaking ? "Stop speaking" : "Listen to response"}
158
+ >
159
+ <ActionIcon
160
+ onClick={() => speakResponse(response)}
161
+ variant="subtle"
162
+ color={isSpeaking ? "blue" : "gray"}
163
+ >
164
+ <IconVolume2 size={16} />
165
+ </ActionIcon>
166
+ </Tooltip>
167
+ {settings.enableAiResponseScrolling ? (
168
+ <Tooltip label="Show full response without scroll bar">
169
+ <ActionIcon
170
+ onClick={() => {
171
+ setSettings({
172
+ ...settings,
173
+ enableAiResponseScrolling: false,
174
+ });
175
+ }}
176
+ variant="subtle"
177
+ color="gray"
178
+ >
179
+ <IconArrowsMaximize size={16} />
180
+ </ActionIcon>
181
+ </Tooltip>
182
+ ) : (
183
+ <Tooltip label="Enable scroll bar">
184
+ <ActionIcon
185
+ onClick={() => {
186
+ setSettings({
187
+ ...settings,
188
+ enableAiResponseScrolling: true,
189
+ });
190
+ }}
191
+ variant="subtle"
192
+ color="gray"
193
+ >
194
+ <IconArrowsMinimize size={16} />
195
+ </ActionIcon>
196
+ </Tooltip>
197
+ )}
198
+ <CopyIconButton value={response} tooltipLabel="Copy response" />
199
+ </Group>
200
+ </Group>
201
+ </Card.Section>
202
+ <Card.Section withBorder>
203
+ <ConditionalScrollArea>
204
+ <FormattedMarkdown>{response}</FormattedMarkdown>
205
+ </ConditionalScrollArea>
206
+ {textGenerationState === "failed" && (
207
+ <Alert
208
+ variant="light"
209
+ color="yellow"
210
+ title="Failed to generate response"
211
+ icon={<IconInfoCircle />}
212
+ >
213
+ Could not generate response. Please try refreshing the page.
214
+ </Alert>
215
+ )}
216
+ </Card.Section>
217
+ </Card>
218
+ );
219
+ }
client/components/AiResponse/AiResponseSection.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CodeHighlightAdapterProvider } from "@mantine/code-highlight";
2
+ import { usePubSub } from "create-pubsub/react";
3
+ import { useMemo } from "react";
4
+ import {
5
+ chatMessagesPubSub,
6
+ isRestoringFromHistoryPubSub,
7
+ modelLoadingProgressPubSub,
8
+ modelSizeInMegabytesPubSub,
9
+ queryPubSub,
10
+ responsePubSub,
11
+ settingsPubSub,
12
+ textGenerationStatePubSub,
13
+ } from "@/modules/pubSub";
14
+ import { shikiAdapter } from "@/modules/shiki";
15
+ import "@mantine/code-highlight/styles.css";
16
+ import AiModelDownloadAllowanceContent from "./AiModelDownloadAllowanceContent";
17
+ import AiResponseContent from "./AiResponseContent";
18
+ import ChatInterface from "./ChatInterface";
19
+ import LoadingModelContent from "./LoadingModelContent";
20
+ import PreparingContent from "./PreparingContent";
21
+
22
+ export default function AiResponseSection() {
23
+ const [query] = usePubSub(queryPubSub);
24
+ const [response] = usePubSub(responsePubSub);
25
+ const [textGenerationState, setTextGenerationState] = usePubSub(
26
+ textGenerationStatePubSub,
27
+ );
28
+ const [modelLoadingProgress] = usePubSub(modelLoadingProgressPubSub);
29
+ const [settings] = usePubSub(settingsPubSub);
30
+ const [modelSizeInMegabytes] = usePubSub(modelSizeInMegabytesPubSub);
31
+ const [chatMessages] = usePubSub(chatMessagesPubSub);
32
+ const [isRestoringFromHistory] = usePubSub(isRestoringFromHistoryPubSub);
33
+
34
+ return useMemo(() => {
35
+ if (!settings.enableAiResponse || textGenerationState === "idle") {
36
+ return null;
37
+ }
38
+
39
+ const generatingStates = [
40
+ "generating",
41
+ "interrupted",
42
+ "completed",
43
+ "failed",
44
+ ];
45
+ if (generatingStates.includes(textGenerationState)) {
46
+ return (
47
+ <CodeHighlightAdapterProvider adapter={shikiAdapter}>
48
+ <AiResponseContent
49
+ textGenerationState={textGenerationState}
50
+ response={response}
51
+ setTextGenerationState={setTextGenerationState}
52
+ />
53
+
54
+ {textGenerationState === "completed" && (
55
+ <ChatInterface
56
+ initialQuery={query}
57
+ initialResponse={response}
58
+ initialMessages={
59
+ chatMessages.length > 0 ? chatMessages : undefined
60
+ }
61
+ suppressInitialFollowUp={isRestoringFromHistory}
62
+ />
63
+ )}
64
+ </CodeHighlightAdapterProvider>
65
+ );
66
+ }
67
+
68
+ if (textGenerationState === "loadingModel") {
69
+ return (
70
+ <LoadingModelContent
71
+ modelLoadingProgress={modelLoadingProgress}
72
+ modelSizeInMegabytes={modelSizeInMegabytes}
73
+ />
74
+ );
75
+ }
76
+
77
+ if (textGenerationState === "preparingToGenerate") {
78
+ return <PreparingContent textGenerationState={textGenerationState} />;
79
+ }
80
+
81
+ if (textGenerationState === "awaitingSearchResults") {
82
+ return <PreparingContent textGenerationState={textGenerationState} />;
83
+ }
84
+
85
+ if (textGenerationState === "awaitingModelDownloadAllowance") {
86
+ return <AiModelDownloadAllowanceContent />;
87
+ }
88
+
89
+ return null;
90
+ }, [
91
+ settings.enableAiResponse,
92
+ textGenerationState,
93
+ response,
94
+ query,
95
+ chatMessages,
96
+ modelLoadingProgress,
97
+ modelSizeInMegabytes,
98
+ setTextGenerationState,
99
+ isRestoringFromHistory,
100
+ ]);
101
+ }
client/components/AiResponse/ChatHeader.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Group, Text } from "@mantine/core";
2
+ import type { ChatMessage } from "@/modules/types";
3
+ import CopyIconButton from "./CopyIconButton";
4
+
5
+ interface ChatHeaderProps {
6
+ messages: ChatMessage[];
7
+ }
8
+
9
+ function ChatHeader({ messages }: ChatHeaderProps) {
10
+ const getChatContent = () => {
11
+ return messages
12
+ .slice(2)
13
+ .map(
14
+ (msg, index) =>
15
+ `${index + 1}. ${msg.role?.toUpperCase()}\n\n${msg.content}`,
16
+ )
17
+ .join("\n\n");
18
+ };
19
+
20
+ return (
21
+ <Group justify="space-between">
22
+ <Text fw={500}>Follow-up questions</Text>
23
+ {messages.length > 2 && (
24
+ <CopyIconButton
25
+ value={getChatContent()}
26
+ tooltipLabel="Copy conversation"
27
+ />
28
+ )}
29
+ </Group>
30
+ );
31
+ }
32
+
33
+ export default ChatHeader;
client/components/AiResponse/ChatInputArea.tsx ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button, Group, Textarea } from "@mantine/core";
2
+ import { IconSend } from "@tabler/icons-react";
3
+ import { usePubSub } from "create-pubsub/react";
4
+ import {
5
+ chatGenerationStatePubSub,
6
+ chatInputPubSub,
7
+ followUpQuestionPubSub,
8
+ isRestoringFromHistoryPubSub,
9
+ suppressNextFollowUpPubSub,
10
+ } from "@/modules/pubSub";
11
+
12
+ interface ChatInputAreaProps {
13
+ onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
14
+ handleSend: (textToSend?: string) => void;
15
+ }
16
+
17
+ function ChatInputArea({ onKeyDown, handleSend }: ChatInputAreaProps) {
18
+ const [input, setInput] = usePubSub(chatInputPubSub);
19
+ const [generationState] = usePubSub(chatGenerationStatePubSub);
20
+ const [followUpQuestion] = usePubSub(followUpQuestionPubSub);
21
+ const [isRestoringFromHistory] = usePubSub(isRestoringFromHistoryPubSub);
22
+ const [suppressNextFollowUp] = usePubSub(suppressNextFollowUpPubSub);
23
+
24
+ const isGenerating =
25
+ generationState.isGeneratingResponse &&
26
+ !generationState.isGeneratingFollowUpQuestion;
27
+
28
+ const defaultPlaceholder = "Anything else you would like to know?";
29
+ const placeholder =
30
+ isRestoringFromHistory || suppressNextFollowUp
31
+ ? defaultPlaceholder
32
+ : followUpQuestion || defaultPlaceholder;
33
+
34
+ const onChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
35
+ setInput(event.target.value);
36
+ };
37
+ const handleKeyDownWithPlaceholder = (
38
+ event: React.KeyboardEvent<HTMLTextAreaElement>,
39
+ ) => {
40
+ if (
41
+ input.trim() === "" &&
42
+ followUpQuestion &&
43
+ !isRestoringFromHistory &&
44
+ !suppressNextFollowUp
45
+ ) {
46
+ if (event.key === "Enter" && !event.shiftKey) {
47
+ event.preventDefault();
48
+ handleSend(followUpQuestion);
49
+ return;
50
+ }
51
+ }
52
+
53
+ onKeyDown(event);
54
+ };
55
+
56
+ const handleSendWithPlaceholder = () => {
57
+ if (
58
+ input.trim() === "" &&
59
+ followUpQuestion &&
60
+ !isRestoringFromHistory &&
61
+ !suppressNextFollowUp
62
+ ) {
63
+ handleSend(followUpQuestion);
64
+ } else {
65
+ handleSend();
66
+ }
67
+ };
68
+
69
+ return (
70
+ <Group align="flex-end" style={{ position: "relative" }}>
71
+ <Textarea
72
+ size="sm"
73
+ aria-label="Chat input"
74
+ placeholder={placeholder}
75
+ value={input}
76
+ onChange={onChange}
77
+ onKeyDown={handleKeyDownWithPlaceholder}
78
+ autosize
79
+ minRows={1}
80
+ maxRows={8}
81
+ style={{ flexGrow: 1, paddingRight: "50px" }}
82
+ disabled={isGenerating}
83
+ />
84
+ <Button
85
+ aria-label="Send message"
86
+ size="sm"
87
+ variant="default"
88
+ onClick={handleSendWithPlaceholder}
89
+ loading={isGenerating}
90
+ style={{
91
+ height: "100%",
92
+ position: "absolute",
93
+ right: 0,
94
+ top: 0,
95
+ bottom: 0,
96
+ borderTopLeftRadius: 0,
97
+ borderBottomLeftRadius: 0,
98
+ }}
99
+ >
100
+ <IconSend size={16} />
101
+ </Button>
102
+ </Group>
103
+ );
104
+ }
105
+
106
+ export default ChatInputArea;
client/components/AiResponse/ChatInterface.tsx ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Card, Stack } from "@mantine/core";
2
+ import { usePubSub } from "create-pubsub/react";
3
+ import {
4
+ type KeyboardEvent,
5
+ useCallback,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import throttle from "throttleit";
11
+ import { generateFollowUpQuestion } from "@/modules/followUpQuestions";
12
+ import {
13
+ getCurrentSearchRunId,
14
+ saveChatMessageForQuery,
15
+ updateSearchResults,
16
+ } from "@/modules/history";
17
+ import { handleEnterKeyDown } from "@/modules/keyboard";
18
+ import { addLogEntry } from "@/modules/logEntries";
19
+ import {
20
+ chatGenerationStatePubSub,
21
+ chatInputPubSub,
22
+ followUpQuestionPubSub,
23
+ getSettings,
24
+ imageSearchResultsPubSub,
25
+ queryPubSub,
26
+ settingsPubSub,
27
+ suppressNextFollowUpPubSub,
28
+ textSearchResultsPubSub,
29
+ updateImageSearchResults,
30
+ updateLlmTextSearchResults,
31
+ updateTextSearchResults,
32
+ } from "@/modules/pubSub";
33
+ import { generateRelatedSearchQuery } from "@/modules/relatedSearchQuery";
34
+ import { searchImages, searchText } from "@/modules/search";
35
+ import { generateChatResponse } from "@/modules/textGeneration";
36
+ import type { ChatMessage } from "@/modules/types";
37
+ import ChatHeader from "./ChatHeader";
38
+ import ChatInputArea from "./ChatInputArea";
39
+ import MessageList from "./MessageList";
40
+
41
+ interface ChatInterfaceProps {
42
+ initialQuery?: string;
43
+ initialResponse?: string;
44
+ initialMessages?: ChatMessage[];
45
+ suppressInitialFollowUp?: boolean;
46
+ }
47
+
48
+ export default function ChatInterface({
49
+ initialQuery,
50
+ initialResponse,
51
+ initialMessages,
52
+ suppressInitialFollowUp,
53
+ }: ChatInterfaceProps) {
54
+ const initialMessagesArray =
55
+ initialMessages &&
56
+ initialMessages.length > 0 &&
57
+ initialQuery &&
58
+ initialResponse
59
+ ? [
60
+ { role: "user" as const, content: initialQuery },
61
+ { role: "assistant" as const, content: initialResponse },
62
+ ...initialMessages,
63
+ ]
64
+ : initialMessages || [];
65
+
66
+ const [messages, setMessages] = useState<ChatMessage[]>(initialMessagesArray);
67
+ const [input, setInput] = usePubSub(chatInputPubSub);
68
+ const [generationState, setGenerationState] = usePubSub(
69
+ chatGenerationStatePubSub,
70
+ );
71
+ const [, setFollowUpQuestion] = usePubSub(followUpQuestionPubSub);
72
+ const [textSearchResults] = usePubSub(textSearchResultsPubSub);
73
+ const [imageSearchResults] = usePubSub(imageSearchResultsPubSub);
74
+ const [currentQuery] = usePubSub(queryPubSub);
75
+ const [suppressNextFollowUp] = usePubSub(suppressNextFollowUpPubSub);
76
+ const [previousFollowUpQuestions, setPreviousFollowUpQuestions] = useState<
77
+ string[]
78
+ >([]);
79
+ const [settings] = usePubSub(settingsPubSub);
80
+ const [streamedResponse, setStreamedResponse] = useState("");
81
+ const hasInitialized = useRef(false);
82
+ const prevInitialMessagesRef = useRef<ChatMessage[] | undefined>(undefined);
83
+ const updateStreamedResponse = useCallback(
84
+ throttle((response: string) => {
85
+ setStreamedResponse(response);
86
+ }, 1000 / 12),
87
+ [],
88
+ );
89
+
90
+ const regenerateFollowUpQuestion = useCallback(
91
+ async (currentQuery: string, currentResponse: string) => {
92
+ if (suppressNextFollowUp) return;
93
+ if (!currentResponse || !currentQuery.trim()) return;
94
+
95
+ try {
96
+ setGenerationState({
97
+ isGeneratingResponse: false,
98
+ isGeneratingFollowUpQuestion: true,
99
+ });
100
+
101
+ const newQuestion = await generateFollowUpQuestion({
102
+ topic: currentQuery,
103
+ currentContent: currentResponse,
104
+ previousQuestions: previousFollowUpQuestions,
105
+ });
106
+
107
+ setPreviousFollowUpQuestions((prev) =>
108
+ [...prev, newQuestion].slice(-5),
109
+ );
110
+ setFollowUpQuestion(newQuestion);
111
+ setGenerationState({
112
+ isGeneratingResponse: false,
113
+ isGeneratingFollowUpQuestion: false,
114
+ });
115
+ } catch (_) {
116
+ setFollowUpQuestion("");
117
+ setGenerationState({
118
+ isGeneratingResponse: false,
119
+ isGeneratingFollowUpQuestion: false,
120
+ });
121
+ }
122
+ },
123
+ [
124
+ setFollowUpQuestion,
125
+ setGenerationState,
126
+ previousFollowUpQuestions,
127
+ suppressNextFollowUp,
128
+ ],
129
+ );
130
+
131
+ useEffect(() => {
132
+ const messagesChanged =
133
+ !prevInitialMessagesRef.current ||
134
+ JSON.stringify(prevInitialMessagesRef.current) !==
135
+ JSON.stringify(initialMessages);
136
+
137
+ if (!messagesChanged) return;
138
+
139
+ prevInitialMessagesRef.current = initialMessages;
140
+
141
+ const newInitialMessagesArray =
142
+ initialMessages &&
143
+ initialMessages.length > 0 &&
144
+ initialQuery &&
145
+ initialResponse
146
+ ? [
147
+ { role: "user" as const, content: initialQuery },
148
+ { role: "assistant" as const, content: initialResponse },
149
+ ...initialMessages,
150
+ ]
151
+ : initialMessages || [];
152
+
153
+ if (newInitialMessagesArray.length > 0) {
154
+ setMessages(newInitialMessagesArray);
155
+ } else if (initialQuery && initialResponse) {
156
+ setMessages([
157
+ { role: "user", content: initialQuery },
158
+ { role: "assistant", content: initialResponse },
159
+ ]);
160
+ }
161
+ }, [initialQuery, initialResponse, initialMessages]);
162
+
163
+ useEffect(() => {
164
+ if (suppressNextFollowUp) {
165
+ hasInitialized.current = true;
166
+ return;
167
+ }
168
+ if (suppressInitialFollowUp) return;
169
+ if (hasInitialized.current) return;
170
+
171
+ if (initialMessages && initialMessages.length > 0) {
172
+ const lastAssistant = messages
173
+ .filter((m) => m.role === "assistant")
174
+ .pop();
175
+ const lastUser = messages.filter((m) => m.role === "user").pop();
176
+ if (lastUser && lastAssistant) {
177
+ regenerateFollowUpQuestion(lastUser.content, lastAssistant.content);
178
+ hasInitialized.current = true;
179
+ }
180
+ } else if (messages.length >= 2 && initialQuery && initialResponse) {
181
+ regenerateFollowUpQuestion(initialQuery, initialResponse);
182
+ hasInitialized.current = true;
183
+ }
184
+ }, [
185
+ initialQuery,
186
+ initialResponse,
187
+ initialMessages,
188
+ messages,
189
+ regenerateFollowUpQuestion,
190
+ suppressInitialFollowUp,
191
+ suppressNextFollowUp,
192
+ ]);
193
+
194
+ useEffect(() => {
195
+ return () => {
196
+ setFollowUpQuestion("");
197
+ setPreviousFollowUpQuestions([]);
198
+ };
199
+ }, [setFollowUpQuestion]);
200
+
201
+ const handleEditMessage = useCallback(
202
+ (absoluteIndex: number) => {
203
+ const target = messages[absoluteIndex];
204
+ if (!target || target.role !== "user") return;
205
+ setInput(target.content);
206
+ setMessages(messages.slice(0, absoluteIndex));
207
+ setFollowUpQuestion("");
208
+ },
209
+ [messages, setInput, setFollowUpQuestion],
210
+ );
211
+
212
+ const handleRegenerateResponse = useCallback(async () => {
213
+ if (
214
+ generationState.isGeneratingResponse ||
215
+ messages.length < 3 ||
216
+ messages[messages.length - 1].role !== "assistant"
217
+ )
218
+ return;
219
+
220
+ const history = messages.slice(0, -1);
221
+ const lastUser = history[history.length - 1];
222
+
223
+ setMessages(history);
224
+ setGenerationState({ ...generationState, isGeneratingResponse: true });
225
+ setFollowUpQuestion("");
226
+ setStreamedResponse("");
227
+
228
+ try {
229
+ const finalResponse = await generateChatResponse(
230
+ history,
231
+ updateStreamedResponse,
232
+ );
233
+
234
+ setMessages((prev) => [
235
+ ...prev,
236
+ { role: "assistant", content: finalResponse },
237
+ ]);
238
+
239
+ addLogEntry("AI response re-generated");
240
+
241
+ if (lastUser?.role === "user") {
242
+ await regenerateFollowUpQuestion(lastUser.content, finalResponse);
243
+ }
244
+ } catch (error) {
245
+ addLogEntry(`Error re-generating response: ${error}`);
246
+ } finally {
247
+ setGenerationState({ ...generationState, isGeneratingResponse: false });
248
+ }
249
+ }, [
250
+ generationState,
251
+ messages,
252
+ regenerateFollowUpQuestion,
253
+ setFollowUpQuestion,
254
+ setGenerationState,
255
+ updateStreamedResponse,
256
+ ]);
257
+
258
+ const handleSend = useCallback(
259
+ async (textToSend?: string) => {
260
+ const currentInput = textToSend ?? input;
261
+ if (currentInput.trim() === "" || generationState.isGeneratingResponse)
262
+ return;
263
+
264
+ const userMessage: ChatMessage = { role: "user", content: currentInput };
265
+ const newMessages: ChatMessage[] = [...messages, userMessage];
266
+
267
+ setMessages(newMessages);
268
+ if (!textToSend) setInput("");
269
+ setGenerationState({
270
+ ...generationState,
271
+ isGeneratingResponse: true,
272
+ });
273
+ setFollowUpQuestion("");
274
+ setStreamedResponse("");
275
+
276
+ try {
277
+ const relatedQuery = await generateRelatedSearchQuery([...newMessages]);
278
+ const searchQuery = relatedQuery || currentInput;
279
+
280
+ if (settings.enableTextSearch) {
281
+ const freshResults = await searchText(
282
+ searchQuery,
283
+ settings.searchResultsLimit,
284
+ );
285
+
286
+ if (freshResults.length > 0) {
287
+ const existingUrls = new Set(
288
+ textSearchResults.map(([, , url]) => url),
289
+ );
290
+
291
+ const uniqueFreshResults = freshResults.filter(
292
+ ([, , url]) => !existingUrls.has(url),
293
+ );
294
+
295
+ updateLlmTextSearchResults(
296
+ freshResults.slice(0, getSettings().searchResultsToConsider),
297
+ );
298
+
299
+ if (uniqueFreshResults.length > 0) {
300
+ const updatedResults = [
301
+ ...textSearchResults,
302
+ ...uniqueFreshResults,
303
+ ];
304
+ updateTextSearchResults(updatedResults);
305
+
306
+ updateSearchResults(getCurrentSearchRunId(), {
307
+ type: "text",
308
+ items: updatedResults.map(([title, snippet, url]) => ({
309
+ title,
310
+ url,
311
+ snippet,
312
+ })),
313
+ });
314
+ }
315
+ }
316
+ }
317
+
318
+ if (settings.enableImageSearch) {
319
+ searchImages(searchQuery, settings.searchResultsLimit)
320
+ .then((imageResults) => {
321
+ if (imageResults.length > 0) {
322
+ const existingUrls = new Set(
323
+ imageSearchResults.map(([, url]) => url),
324
+ );
325
+
326
+ const uniqueFreshResults = imageResults.filter(
327
+ ([, url]) => !existingUrls.has(url),
328
+ );
329
+
330
+ if (uniqueFreshResults.length > 0) {
331
+ const updatedImageResults = [
332
+ ...uniqueFreshResults,
333
+ ...imageSearchResults,
334
+ ];
335
+ updateImageSearchResults(updatedImageResults);
336
+
337
+ updateSearchResults(getCurrentSearchRunId(), {
338
+ type: "image",
339
+ items: updatedImageResults.map(
340
+ ([title, url, thumbnailUrl, sourceUrl]) => ({
341
+ title,
342
+ url,
343
+ thumbnail: thumbnailUrl,
344
+ sourceUrl,
345
+ }),
346
+ ),
347
+ });
348
+ }
349
+ }
350
+ })
351
+ .catch((error) => {
352
+ addLogEntry(`Error in follow-up image search: ${error}`);
353
+ });
354
+ }
355
+ } catch (error) {
356
+ addLogEntry(`Error in follow-up search: ${error}`);
357
+ }
358
+
359
+ try {
360
+ const finalResponse = await generateChatResponse(
361
+ newMessages,
362
+ updateStreamedResponse,
363
+ );
364
+
365
+ setMessages((prevMessages) => [
366
+ ...prevMessages,
367
+ { role: "assistant", content: finalResponse },
368
+ ]);
369
+
370
+ addLogEntry("AI response completed");
371
+
372
+ await saveChatMessageForQuery(currentQuery, "user", currentInput);
373
+ await saveChatMessageForQuery(currentQuery, "assistant", finalResponse);
374
+
375
+ await regenerateFollowUpQuestion(currentInput, finalResponse);
376
+ } catch (error) {
377
+ addLogEntry(`Error in chat response: ${error}`);
378
+ setMessages((prevMessages) => [
379
+ ...prevMessages,
380
+ {
381
+ role: "assistant",
382
+ content:
383
+ "Sorry, I encountered an error while generating a response.",
384
+ },
385
+ ]);
386
+ } finally {
387
+ setGenerationState({
388
+ ...generationState,
389
+ isGeneratingResponse: false,
390
+ });
391
+ }
392
+ },
393
+ [
394
+ generationState,
395
+ messages,
396
+ settings,
397
+ input,
398
+ regenerateFollowUpQuestion,
399
+ setFollowUpQuestion,
400
+ setGenerationState,
401
+ setInput,
402
+ updateStreamedResponse,
403
+ currentQuery,
404
+ textSearchResults,
405
+ imageSearchResults,
406
+ ],
407
+ );
408
+
409
+ const handleKeyDown = useCallback(
410
+ (event: KeyboardEvent<HTMLTextAreaElement>) => {
411
+ handleEnterKeyDown(event, settings, handleSend);
412
+ },
413
+ [settings, handleSend],
414
+ );
415
+
416
+ return (
417
+ <Card withBorder shadow="sm" radius="md">
418
+ <Card.Section withBorder inheritPadding py="xs">
419
+ <ChatHeader messages={messages} />
420
+ </Card.Section>
421
+ <Stack gap="md" pt="md">
422
+ <MessageList
423
+ messages={
424
+ generationState.isGeneratingResponse
425
+ ? [...messages, { role: "assistant", content: streamedResponse }]
426
+ : messages
427
+ }
428
+ onEditMessage={handleEditMessage}
429
+ onRegenerate={handleRegenerateResponse}
430
+ isGenerating={generationState.isGeneratingResponse}
431
+ />
432
+ <ChatInputArea onKeyDown={handleKeyDown} handleSend={handleSend} />
433
+ </Stack>
434
+ </Card>
435
+ );
436
+ }
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/EnableAiResponsePrompt.tsx ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ ActionIcon,
3
+ Alert,
4
+ Button,
5
+ Grid,
6
+ Group,
7
+ Popover,
8
+ Stack,
9
+ Text,
10
+ } from "@mantine/core";
11
+ import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
12
+
13
+ interface EnableAiResponsePromptProps {
14
+ onAccept: () => void;
15
+ onDecline: () => void;
16
+ }
17
+
18
+ export default function EnableAiResponsePrompt({
19
+ onAccept,
20
+ onDecline,
21
+ }: EnableAiResponsePromptProps) {
22
+ const helpContent = (
23
+ <Stack gap="xs" p="xs">
24
+ <Text size="sm">
25
+ MiniSearch is a web-searching app with an integrated AI assistant.
26
+ </Text>
27
+ <Text size="sm">
28
+ With AI Responses enabled, it will generate summaries and answer
29
+ questions based on search results.
30
+ </Text>
31
+ <Text size="sm">
32
+ If disabled, it will function as a classic web search tool.
33
+ </Text>
34
+ <Text size="sm" c="dimmed" component="em">
35
+ You can toggle this feature at anytime through the Menu.
36
+ </Text>
37
+ </Stack>
38
+ );
39
+
40
+ return (
41
+ <Alert variant="light" color="blue" p="xs">
42
+ <Grid justify="space-between" align="center">
43
+ <Grid.Col span="content">
44
+ <Group gap="xs">
45
+ <Text fw={500}>Enable AI Responses?</Text>
46
+ <Popover
47
+ width={300}
48
+ styles={{ dropdown: { maxWidth: "92vw" } }}
49
+ position="bottom"
50
+ withArrow
51
+ shadow="md"
52
+ >
53
+ <Popover.Target>
54
+ <ActionIcon variant="subtle" color="blue" size="sm">
55
+ <IconInfoCircle size="1rem" />
56
+ </ActionIcon>
57
+ </Popover.Target>
58
+ <Popover.Dropdown>{helpContent}</Popover.Dropdown>
59
+ </Popover>
60
+ </Group>
61
+ </Grid.Col>
62
+ <Grid.Col span="auto">
63
+ <Group justify="end">
64
+ <Button
65
+ variant="subtle"
66
+ color="gray"
67
+ leftSection={<IconX size="1rem" />}
68
+ onClick={onDecline}
69
+ size="xs"
70
+ >
71
+ No, thanks
72
+ </Button>
73
+ <Button
74
+ leftSection={<IconCheck size="1rem" />}
75
+ onClick={onAccept}
76
+ size="xs"
77
+ >
78
+ Yes, please
79
+ </Button>
80
+ </Group>
81
+ </Grid.Col>
82
+ </Grid>
83
+ </Alert>
84
+ );
85
+ }
client/components/AiResponse/ExpandableLink.tsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MantineTheme } from "@mantine/core";
2
+ import { Button } from "@mantine/core";
3
+ import React from "react";
4
+
5
+ interface ExpandableLinkProps {
6
+ href: string;
7
+ children: React.ReactNode;
8
+ }
9
+
10
+ export default function ExpandableLink({
11
+ href,
12
+ children,
13
+ }: ExpandableLinkProps) {
14
+ const childContent = children?.toString() || "";
15
+ const firstChar = childContent.charAt(0);
16
+ const [isExpanded, setIsExpanded] = React.useState(true);
17
+ const timerRef = React.useRef<number | null>(null);
18
+
19
+ React.useEffect(() => {
20
+ timerRef.current = window.setTimeout(() => {
21
+ setIsExpanded(false);
22
+ timerRef.current = null;
23
+ }, 3000);
24
+
25
+ return () => {
26
+ if (timerRef.current) {
27
+ clearTimeout(timerRef.current);
28
+ }
29
+ };
30
+ }, []);
31
+
32
+ const handleMouseEnter = () => {
33
+ if (timerRef.current) {
34
+ clearTimeout(timerRef.current);
35
+ timerRef.current = null;
36
+ }
37
+ setIsExpanded(true);
38
+ };
39
+
40
+ const handleMouseLeave = () => {
41
+ timerRef.current = window.setTimeout(() => {
42
+ setIsExpanded(false);
43
+ timerRef.current = null;
44
+ }, 3000);
45
+ };
46
+
47
+ const fullTextRef = React.useRef<HTMLDivElement>(null);
48
+ const [fullTextWidth, setFullTextWidth] = React.useState(0);
49
+
50
+ React.useEffect(() => {
51
+ const measureText = () => {
52
+ if (fullTextRef.current) {
53
+ setFullTextWidth(fullTextRef.current.scrollWidth);
54
+ }
55
+ };
56
+
57
+ measureText();
58
+
59
+ window.addEventListener("resize", measureText);
60
+ return () => {
61
+ window.removeEventListener("resize", measureText);
62
+ };
63
+ }, []);
64
+
65
+ return (
66
+ <Button
67
+ component="a"
68
+ href={href}
69
+ target="_blank"
70
+ rel="nofollow noopener noreferrer"
71
+ variant="light"
72
+ color="gray"
73
+ size="compact-xs"
74
+ radius="xl"
75
+ style={(theme: MantineTheme) => ({
76
+ textDecoration: "none",
77
+ transform: "translateY(-2px)",
78
+ overflow: "hidden",
79
+ position: "relative",
80
+ width: isExpanded ? `${fullTextWidth + theme.spacing.md}px` : "2em",
81
+ transition: "width 0.3s ease-in-out",
82
+ textAlign: "center",
83
+ })}
84
+ onMouseEnter={handleMouseEnter}
85
+ onMouseLeave={handleMouseLeave}
86
+ onFocus={handleMouseEnter}
87
+ onBlur={handleMouseLeave}
88
+ >
89
+ <span
90
+ style={{
91
+ position: "absolute",
92
+ top: 0,
93
+ left: 0,
94
+ right: 0,
95
+ bottom: 0,
96
+ display: "flex",
97
+ alignItems: "center",
98
+ justifyContent: "center",
99
+ opacity: isExpanded ? 0 : 1,
100
+ transition: "opacity 0.2s ease-in-out",
101
+ }}
102
+ >
103
+ {firstChar}
104
+ </span>
105
+ <span
106
+ ref={fullTextRef}
107
+ style={{
108
+ opacity: isExpanded ? 1 : 0,
109
+ transition: "opacity 0.3s ease-in-out",
110
+ visibility: isExpanded ? "visible" : "hidden",
111
+ whiteSpace: "nowrap",
112
+ display: "flex",
113
+ alignItems: "center",
114
+ justifyContent: "center",
115
+ height: "100%",
116
+ position: "relative",
117
+ }}
118
+ >
119
+ {children}
120
+ </span>
121
+ </Button>
122
+ );
123
+ }
client/components/AiResponse/FormattedMarkdown.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { TypographyStylesProvider } from "@mantine/core";
2
+ import { useReasoningContent } from "./hooks/useReasoningContent";
3
+ import MarkdownRenderer from "./MarkdownRenderer";
4
+ import ReasoningSection from "./ReasoningSection";
5
+
6
+ interface FormattedMarkdownProps {
7
+ children: string;
8
+ className?: string;
9
+ enableCopy?: boolean;
10
+ }
11
+
12
+ export default function FormattedMarkdown({
13
+ children,
14
+ className = "",
15
+ enableCopy = true,
16
+ }: FormattedMarkdownProps) {
17
+ const { reasoningContent, mainContent, isGenerating } =
18
+ useReasoningContent(children);
19
+
20
+ if (!children && !reasoningContent) {
21
+ return null;
22
+ }
23
+
24
+ return (
25
+ <TypographyStylesProvider p="lg">
26
+ {reasoningContent && (
27
+ <ReasoningSection
28
+ content={reasoningContent}
29
+ isGenerating={isGenerating}
30
+ />
31
+ )}
32
+ {!isGenerating && mainContent && (
33
+ <MarkdownRenderer
34
+ content={mainContent}
35
+ enableCopy={enableCopy}
36
+ className={className}
37
+ />
38
+ )}
39
+ </TypographyStylesProvider>
40
+ );
41
+ }
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/MarkdownRenderer.tsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CodeHighlight } from "@mantine/code-highlight";
2
+ import { Blockquote, Box, Code, Divider, Text } from "@mantine/core";
3
+ import React from "react";
4
+ import { ErrorBoundary } from "react-error-boundary";
5
+ import Markdown from "react-markdown";
6
+ import rehypeExternalLinks from "rehype-external-links";
7
+ import remarkGfm from "remark-gfm";
8
+ import ExpandableLink from "./ExpandableLink";
9
+
10
+ interface MarkdownRendererProps {
11
+ content: string;
12
+ enableCopy?: boolean;
13
+ className?: string;
14
+ }
15
+
16
+ export default function MarkdownRenderer({
17
+ content,
18
+ enableCopy = true,
19
+ className = "",
20
+ }: MarkdownRendererProps) {
21
+ if (!content) {
22
+ return null;
23
+ }
24
+
25
+ const unwrapParagraphs = (children: React.ReactNode) => {
26
+ return React.Children.map(children, (child) => {
27
+ if (React.isValidElement(child) && child.type === "p") {
28
+ return (child.props as { children: React.ReactNode }).children;
29
+ }
30
+ return child;
31
+ });
32
+ };
33
+
34
+ return (
35
+ <Box className={className}>
36
+ <Markdown
37
+ remarkPlugins={[remarkGfm]}
38
+ rehypePlugins={[
39
+ [
40
+ rehypeExternalLinks,
41
+ { target: "_blank", rel: ["nofollow", "noopener", "noreferrer"] },
42
+ ],
43
+ ]}
44
+ components={{
45
+ a(props) {
46
+ const { href, children } = props;
47
+ return (
48
+ <ExpandableLink href={href || ""}>{children}</ExpandableLink>
49
+ );
50
+ },
51
+ li(props) {
52
+ const { children } = props;
53
+ return <li>{unwrapParagraphs(children)}</li>;
54
+ },
55
+ hr() {
56
+ return <Divider variant="dashed" my="md" />;
57
+ },
58
+ pre(props) {
59
+ return <>{props.children}</>;
60
+ },
61
+ blockquote(props) {
62
+ const { children } = props;
63
+ return (
64
+ <Blockquote>
65
+ <Text size="md">{unwrapParagraphs(children)}</Text>
66
+ </Blockquote>
67
+ );
68
+ },
69
+ code(props) {
70
+ const { children, className, node } = props;
71
+ const codeContent = children?.toString().replace(/\n$/, "") ?? "";
72
+ let language = "text";
73
+
74
+ if (className) {
75
+ const languageMatch = /language-(\w+)/.exec(className);
76
+ if (languageMatch) language = languageMatch[1];
77
+ }
78
+
79
+ if (
80
+ language === "text" &&
81
+ node?.position?.end.line === node?.position?.start.line
82
+ ) {
83
+ return <Code>{codeContent}</Code>;
84
+ }
85
+
86
+ return (
87
+ <ErrorBoundary fallback={<Code block>{codeContent}</Code>}>
88
+ <CodeHighlight
89
+ code={codeContent}
90
+ language={language}
91
+ radius="md"
92
+ withCopyButton={enableCopy}
93
+ mb="xs"
94
+ />
95
+ </ErrorBoundary>
96
+ );
97
+ },
98
+ }}
99
+ >
100
+ {content}
101
+ </Markdown>
102
+ </Box>
103
+ );
104
+ }
client/components/AiResponse/MessageList.tsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ActionIcon, Group, Paper, Stack, Tooltip } from "@mantine/core";
2
+ import { IconPencil, IconRefresh } from "@tabler/icons-react";
3
+ import { memo } from "react";
4
+ import type { ChatMessage } from "@/modules/types";
5
+ import FormattedMarkdown from "./FormattedMarkdown";
6
+
7
+ interface MessageListProps {
8
+ messages: ChatMessage[];
9
+ onEditMessage: (absoluteIndex: number) => void;
10
+ onRegenerate: () => void;
11
+ isGenerating: boolean;
12
+ }
13
+
14
+ interface MessageProps {
15
+ message: ChatMessage;
16
+ index: number;
17
+ absoluteIndex: number;
18
+ isLastAssistant: boolean;
19
+ isGenerating: boolean;
20
+ onEditMessage: (absoluteIndex: number) => void;
21
+ onRegenerate: () => void;
22
+ }
23
+
24
+ const Message = memo(
25
+ ({
26
+ message,
27
+ index,
28
+ absoluteIndex,
29
+ isLastAssistant,
30
+ isGenerating,
31
+ onEditMessage,
32
+ onRegenerate,
33
+ }: MessageProps) => {
34
+ const canEdit = message.role === "user";
35
+ const canRegenerate = isLastAssistant && message.role === "assistant";
36
+ const iconSize = 16;
37
+ const iconVariant: "subtle" = "subtle";
38
+
39
+ return (
40
+ <Group
41
+ gap="xs"
42
+ align="center"
43
+ w="100%"
44
+ justify={message.role === "user" ? "flex-end" : "flex-start"}
45
+ >
46
+ {canEdit && (
47
+ <Tooltip label="Edit" withArrow position="right" openDelay={300}>
48
+ <ActionIcon
49
+ aria-label="Edit message"
50
+ color="gray"
51
+ variant={iconVariant}
52
+ disabled={isGenerating}
53
+ onClick={() => onEditMessage(absoluteIndex)}
54
+ >
55
+ <IconPencil size={iconSize} />
56
+ </ActionIcon>
57
+ </Tooltip>
58
+ )}
59
+
60
+ <Paper
61
+ key={`${message.role}-${index}`}
62
+ shadow="xs"
63
+ radius="xl"
64
+ p="sm"
65
+ style={{ flex: 1, overflow: "auto" }}
66
+ >
67
+ <FormattedMarkdown>{message.content}</FormattedMarkdown>
68
+ </Paper>
69
+
70
+ {canRegenerate && (
71
+ <Tooltip
72
+ label="Re-generate response"
73
+ withArrow
74
+ position="left"
75
+ openDelay={300}
76
+ >
77
+ <ActionIcon
78
+ aria-label="Re-generate response"
79
+ color="gray"
80
+ variant={iconVariant}
81
+ disabled={isGenerating}
82
+ onClick={() => onRegenerate()}
83
+ >
84
+ <IconRefresh size={iconSize} />
85
+ </ActionIcon>
86
+ </Tooltip>
87
+ )}
88
+ </Group>
89
+ );
90
+ },
91
+ );
92
+
93
+ const MessageList = memo(function MessageList({
94
+ messages,
95
+ onEditMessage,
96
+ onRegenerate,
97
+ isGenerating,
98
+ }: MessageListProps) {
99
+ if (messages.length <= 2) return null;
100
+
101
+ return (
102
+ <Stack gap="md">
103
+ {messages
104
+ .slice(2)
105
+ .filter((message) => message.content.length > 0)
106
+ .map((message, index) => {
107
+ const absoluteIndex = index + 2;
108
+ const isLastAssistant =
109
+ absoluteIndex === messages.length - 1 &&
110
+ message.role === "assistant";
111
+ return (
112
+ <Message
113
+ key={`${message.role}-${index}`}
114
+ message={message}
115
+ index={index}
116
+ absoluteIndex={absoluteIndex}
117
+ isLastAssistant={isLastAssistant}
118
+ isGenerating={isGenerating}
119
+ onEditMessage={onEditMessage}
120
+ onRegenerate={onRegenerate}
121
+ />
122
+ );
123
+ })}
124
+ </Stack>
125
+ );
126
+ });
127
+
128
+ export default MessageList;
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/ReasoningSection.tsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Box,
3
+ Collapse,
4
+ Flex,
5
+ Group,
6
+ Loader,
7
+ Text,
8
+ UnstyledButton,
9
+ } from "@mantine/core";
10
+ import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
11
+ import { useEffect, useState } from "react";
12
+ import MarkdownRenderer from "./MarkdownRenderer";
13
+
14
+ interface ReasoningSectionProps {
15
+ content: string;
16
+ isGenerating?: boolean;
17
+ }
18
+
19
+ export default function ReasoningSection({
20
+ content,
21
+ isGenerating = false,
22
+ }: ReasoningSectionProps) {
23
+ const [isOpen, setIsOpen] = useState(isGenerating);
24
+
25
+ useEffect(() => {
26
+ if (isGenerating) {
27
+ setIsOpen(true);
28
+ } else {
29
+ setIsOpen(false);
30
+ }
31
+ }, [isGenerating]);
32
+
33
+ return (
34
+ <Box mb="xs">
35
+ <UnstyledButton
36
+ onClick={() => setIsOpen(!isOpen)}
37
+ style={(theme) => ({
38
+ width: "100%",
39
+ padding: theme.spacing.xs,
40
+ borderStartStartRadius: theme.radius.md,
41
+ borderStartEndRadius: theme.radius.md,
42
+ borderEndEndRadius: !isOpen ? theme.radius.md : 0,
43
+ borderEndStartRadius: !isOpen ? theme.radius.md : 0,
44
+ backgroundColor: theme.colors.dark[8],
45
+ "&:hover": {
46
+ backgroundColor: theme.colors.dark[5],
47
+ },
48
+ cursor: isOpen ? "zoom-out" : "zoom-in",
49
+ })}
50
+ >
51
+ <Group gap={3}>
52
+ {isOpen ? (
53
+ <IconChevronDown size={16} />
54
+ ) : (
55
+ <IconChevronRight size={16} />
56
+ )}
57
+ <Flex align="center" gap={6}>
58
+ <Text size="sm" c="dimmed" fs="italic" span>
59
+ {isGenerating ? "Thinking" : "Thought Process"}
60
+ </Text>
61
+ {isGenerating && <Loader size="sm" color="dimmed" type="dots" />}
62
+ </Flex>
63
+ </Group>
64
+ </UnstyledButton>
65
+ <Collapse in={isOpen}>
66
+ <Box
67
+ style={(theme) => ({
68
+ backgroundColor: theme.colors.dark[8],
69
+ padding: theme.spacing.sm,
70
+ borderBottomLeftRadius: theme.radius.md,
71
+ borderBottomRightRadius: theme.radius.md,
72
+ })}
73
+ >
74
+ <MarkdownRenderer content={content} enableCopy={false} />
75
+ </Box>
76
+ </Collapse>
77
+ </Box>
78
+ );
79
+ }
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/AiResponse/hooks/useReasoningContent.test.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { renderHook } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { useReasoningContent } from "./useReasoningContent";
4
+
5
+ const expectEmptyState = (result: {
6
+ current: ReturnType<typeof useReasoningContent>;
7
+ }) => {
8
+ expect(result.current.reasoningContent).toBe("");
9
+ expect(result.current.mainContent).toBe("");
10
+ expect(result.current.isGenerating).toBe(false);
11
+ };
12
+
13
+ describe("useReasoningContent hook", () => {
14
+ describe("parsing reasoning content from markdown markers", () => {
15
+ it("should extract reasoning content between start and end markers", () => {
16
+ const { result } = renderHook(() =>
17
+ useReasoningContent(
18
+ "<think>Let me think about this</think>\nHere is the answer.",
19
+ ),
20
+ );
21
+
22
+ expect(result.current.reasoningContent).toBe("Let me think about this");
23
+ expect(result.current.mainContent).toBe("\nHere is the answer.");
24
+ expect(result.current.isGenerating).toBe(false);
25
+ });
26
+
27
+ it("should handle empty text", () => {
28
+ const { result } = renderHook(() => useReasoningContent(""));
29
+ expectEmptyState(result);
30
+ });
31
+
32
+ it("should return text as main content when no markers present", () => {
33
+ const { result } = renderHook(() =>
34
+ useReasoningContent("This is a normal response."),
35
+ );
36
+
37
+ expect(result.current.reasoningContent).toBe("");
38
+ expect(result.current.mainContent).toBe("This is a normal response.");
39
+ expect(result.current.isGenerating).toBe(false);
40
+ });
41
+
42
+ it("should detect generating state when end marker is missing", () => {
43
+ const { result } = renderHook(() =>
44
+ useReasoningContent("<think>I'm still thinking"),
45
+ );
46
+
47
+ expect(result.current.reasoningContent).toBe("I'm still thinking");
48
+ expect(result.current.mainContent).toBe("");
49
+ expect(result.current.isGenerating).toBe(true);
50
+ });
51
+
52
+ it("should handle whitespace-only content", () => {
53
+ const { result } = renderHook(() => useReasoningContent(" "));
54
+ expectEmptyState(result);
55
+ });
56
+
57
+ it("should handle null/undefined content gracefully", () => {
58
+ const { result } = renderHook(() =>
59
+ useReasoningContent(null as unknown as string),
60
+ );
61
+ expectEmptyState(result);
62
+ });
63
+ });
64
+
65
+ describe("UI state management for reasoning section", () => {
66
+ it("should provide correct isGenerating state for accordion title", () => {
67
+ const streamingState = renderHook(() =>
68
+ useReasoningContent("<think>Currently thinking..."),
69
+ );
70
+
71
+ expect(streamingState.result.current.isGenerating).toBe(true);
72
+
73
+ const completedState = renderHook(() =>
74
+ useReasoningContent(
75
+ "<think>Thought process completed</think>\nHere is the answer.",
76
+ ),
77
+ );
78
+
79
+ expect(completedState.result.current.isGenerating).toBe(false);
80
+ });
81
+
82
+ it("should handle transition from streaming to completed state", () => {
83
+ const initial = renderHook(() =>
84
+ useReasoningContent("<think>Building response..."),
85
+ );
86
+ expect(initial.result.current.isGenerating).toBe(true);
87
+
88
+ const transitioned = renderHook(() =>
89
+ useReasoningContent(
90
+ "<think>Response built.</think>\nFinal answer here.",
91
+ ),
92
+ );
93
+ expect(transitioned.result.current.isGenerating).toBe(false);
94
+ });
95
+ });
96
+ });
client/components/AiResponse/hooks/useReasoningContent.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { usePubSub } from "create-pubsub/react";
2
+ import { useCallback } from "react";
3
+ import { settingsPubSub } from "@/modules/pubSub";
4
+
5
+ /**
6
+ * Hook for extracting reasoning content from AI responses
7
+ * @param text - The full text response from the AI
8
+ * @returns Object containing separated reasoning and main content
9
+ */
10
+ export function useReasoningContent(text: string) {
11
+ const [settings] = usePubSub(settingsPubSub);
12
+
13
+ const extractReasoningAndMainContent = useCallback(
14
+ (text: string, startMarker: string, endMarker: string) => {
15
+ if (!text)
16
+ return { reasoningContent: "", mainContent: "", isGenerating: false };
17
+
18
+ const trimmedText = text.trim();
19
+
20
+ if (!trimmedText.startsWith(startMarker))
21
+ return { reasoningContent: "", mainContent: text, isGenerating: false };
22
+
23
+ const startIndex = trimmedText.indexOf(startMarker);
24
+ const endIndex = trimmedText.indexOf(endMarker);
25
+
26
+ if (endIndex === -1) {
27
+ return {
28
+ reasoningContent: trimmedText.slice(startIndex + startMarker.length),
29
+ mainContent: "",
30
+ isGenerating: true,
31
+ };
32
+ }
33
+
34
+ return {
35
+ reasoningContent: trimmedText.slice(
36
+ startIndex + startMarker.length,
37
+ endIndex,
38
+ ),
39
+ mainContent: trimmedText.slice(endIndex + endMarker.length),
40
+ isGenerating: false,
41
+ };
42
+ },
43
+ [],
44
+ );
45
+
46
+ if (text && text.trim() === "") {
47
+ return {
48
+ reasoningContent: "",
49
+ mainContent: "",
50
+ isGenerating: false,
51
+ };
52
+ }
53
+
54
+ if (!text) {
55
+ return {
56
+ reasoningContent: "",
57
+ mainContent: "",
58
+ isGenerating: false,
59
+ };
60
+ }
61
+
62
+ const result = extractReasoningAndMainContent(
63
+ text,
64
+ settings.reasoningStartMarker,
65
+ settings.reasoningEndMarker,
66
+ );
67
+
68
+ return result;
69
+ }
client/components/Analytics/SearchStats.tsx ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Badge,
3
+ Card,
4
+ Center,
5
+ Group,
6
+ Progress,
7
+ SimpleGrid,
8
+ Stack,
9
+ Text,
10
+ ThemeIcon,
11
+ Title,
12
+ } from "@mantine/core";
13
+ import { IconSearch } from "@tabler/icons-react";
14
+ import { useMemo } from "react";
15
+ import { useSearchHistory } from "@/hooks/useSearchHistory";
16
+ import type { SearchEntry } from "@/modules/history";
17
+ import { formatRelativeTime } from "@/modules/stringFormatters";
18
+
19
+ interface SearchStatsProps {
20
+ period?: "today" | "week" | "month" | "all";
21
+ compact?: boolean;
22
+ }
23
+
24
+ interface StatsData {
25
+ totalSearches: number;
26
+ avgPerDay: number;
27
+ mostActiveHour: number;
28
+ topSources: { source: string; count: number; percentage: number }[];
29
+ recentActivity: SearchEntry[];
30
+ searchTrends: { date: string; count: number }[];
31
+ }
32
+
33
+ export default function SearchStats({
34
+ period = "week",
35
+ compact = false,
36
+ }: SearchStatsProps) {
37
+ const { recentSearches, isLoading } = useSearchHistory({ limit: 1000 });
38
+
39
+ const stats = useMemo((): StatsData => {
40
+ if (!recentSearches.length) {
41
+ return {
42
+ totalSearches: 0,
43
+ avgPerDay: 0,
44
+ mostActiveHour: 0,
45
+ topSources: [],
46
+ recentActivity: [],
47
+ searchTrends: [],
48
+ };
49
+ }
50
+
51
+ const now = new Date();
52
+ const filterDate = new Date();
53
+
54
+ switch (period) {
55
+ case "today":
56
+ filterDate.setHours(0, 0, 0, 0);
57
+ break;
58
+ case "week":
59
+ filterDate.setDate(now.getDate() - 7);
60
+ break;
61
+ case "month":
62
+ filterDate.setDate(now.getDate() - 30);
63
+ break;
64
+ default:
65
+ filterDate.setFullYear(2000);
66
+ }
67
+
68
+ const filteredSearches = recentSearches.filter(
69
+ (search) => search.timestamp >= filterDate.getTime(),
70
+ );
71
+
72
+ const totalSearches = filteredSearches.length;
73
+
74
+ const dateCounts = new Map<string, number>();
75
+ filteredSearches.forEach((search) => {
76
+ const date = new Date(search.timestamp).toISOString().split("T")[0];
77
+ dateCounts.set(date, (dateCounts.get(date) || 0) + 1);
78
+ });
79
+
80
+ const uniqueDays = dateCounts.size;
81
+ const avgPerDay =
82
+ uniqueDays > 0 ? Math.round(totalSearches / uniqueDays) : 0;
83
+
84
+ const hourCounts = new Array(24).fill(0);
85
+ filteredSearches.forEach((search) => {
86
+ const hour = new Date(search.timestamp).getHours();
87
+ hourCounts[hour]++;
88
+ });
89
+ const mostActiveHour = hourCounts.indexOf(Math.max(...hourCounts));
90
+
91
+ const sourceCounts = filteredSearches.reduce(
92
+ (acc, search) => {
93
+ if (compact && search.source?.toLowerCase() === "user") {
94
+ return acc;
95
+ }
96
+ const source = search.source || "unknown";
97
+ acc[source] = (acc[source] || 0) + 1;
98
+ return acc;
99
+ },
100
+ {} as Record<string, number>,
101
+ );
102
+
103
+ const sourcesTotal = Object.values(sourceCounts).reduce(
104
+ (sum, n) => sum + n,
105
+ 0,
106
+ );
107
+ const topSources = Object.entries(sourceCounts)
108
+ .map(([source, count]) => ({
109
+ source: source.charAt(0).toUpperCase() + source.slice(1),
110
+ count,
111
+ percentage:
112
+ sourcesTotal > 0 ? Math.round((count / sourcesTotal) * 100) : 0,
113
+ }))
114
+ .sort((a, b) => b.count - a.count);
115
+
116
+ const recentActivity = filteredSearches
117
+ .sort((a, b) => b.timestamp - a.timestamp)
118
+ .slice(0, 10);
119
+
120
+ const searchTrends = Array.from(dateCounts.entries())
121
+ .map(([date, count]) => ({ date, count }))
122
+ .sort((a, b) => a.date.localeCompare(b.date));
123
+
124
+ return {
125
+ totalSearches,
126
+ avgPerDay,
127
+ mostActiveHour,
128
+ topSources,
129
+ recentActivity,
130
+ searchTrends,
131
+ };
132
+ }, [recentSearches, period, compact]);
133
+
134
+ const getSourceColor = (source: string) => {
135
+ const colors = {
136
+ User: "blue",
137
+ Followup: "green",
138
+ Suggestion: "orange",
139
+ Unknown: "gray",
140
+ };
141
+ return colors[source as keyof typeof colors] || "gray";
142
+ };
143
+
144
+ const formatHour = (hour: number) => {
145
+ const period = hour >= 12 ? "PM" : "AM";
146
+ const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
147
+ return `${displayHour}:00 ${period}`;
148
+ };
149
+
150
+ if (isLoading) {
151
+ return (
152
+ <Card withBorder>
153
+ <Center h={200}>
154
+ <Text c="dimmed">Loading analytics...</Text>
155
+ </Center>
156
+ </Card>
157
+ );
158
+ }
159
+
160
+ if (stats.totalSearches === 0) {
161
+ return (
162
+ <Card withBorder>
163
+ <Center h={200}>
164
+ <Stack align="center" gap="xs">
165
+ <ThemeIcon size="xl" variant="light" color="gray">
166
+ <IconSearch size={24} />
167
+ </ThemeIcon>
168
+ <Text c="dimmed">No search data available</Text>
169
+ <Text size="sm" c="dimmed">
170
+ Start searching to see analytics
171
+ </Text>
172
+ </Stack>
173
+ </Center>
174
+ </Card>
175
+ );
176
+ }
177
+
178
+ function MetricCard({
179
+ title,
180
+ value,
181
+ }: {
182
+ title: string;
183
+ value: string | number;
184
+ }) {
185
+ return (
186
+ <Card withBorder p={compact ? "sm" : "md"}>
187
+ <Stack gap="xs" align="flex-start">
188
+ <Text size="xs" tt="uppercase" fw={700} c="dimmed">
189
+ {title}
190
+ </Text>
191
+ <Text fw={700} size={compact ? "lg" : "xl"}>
192
+ {value}
193
+ </Text>
194
+ </Stack>
195
+ </Card>
196
+ );
197
+ }
198
+
199
+ return (
200
+ <Stack gap={compact ? "sm" : "md"}>
201
+ <SimpleGrid cols={compact ? 2 : { base: 1, sm: 2, lg: 4 }}>
202
+ <MetricCard
203
+ title="Total Searches"
204
+ value={stats.totalSearches.toLocaleString()}
205
+ />
206
+ <MetricCard title="Daily Average" value={stats.avgPerDay} />
207
+ <MetricCard
208
+ title="Most Active Hour"
209
+ value={formatHour(stats.mostActiveHour)}
210
+ />
211
+ <MetricCard
212
+ title="Last Search"
213
+ value={
214
+ stats.recentActivity.length > 0
215
+ ? formatRelativeTime(stats.recentActivity[0].timestamp)
216
+ : "Never"
217
+ }
218
+ />
219
+ </SimpleGrid>
220
+
221
+ <SimpleGrid cols={compact ? 1 : { base: 1, md: 2 }}>
222
+ {!(compact && stats.topSources.length === 0) && (
223
+ <Card withBorder>
224
+ <Card.Section p={compact ? "sm" : "md"}>
225
+ <Title order={compact ? 5 : 4} mb={compact ? "xs" : "md"}>
226
+ Search Sources
227
+ </Title>
228
+
229
+ {stats.topSources.length > 0 ? (
230
+ <Stack gap="xs">
231
+ {stats.topSources.map((source) => (
232
+ <Group key={source.source} justify="space-between">
233
+ <Group gap="xs">
234
+ <Badge
235
+ color={getSourceColor(source.source)}
236
+ variant="dot"
237
+ size="sm"
238
+ >
239
+ {source.source}
240
+ </Badge>
241
+ <Text size={compact ? "xs" : "sm"}>
242
+ {source.count} searches
243
+ </Text>
244
+ </Group>
245
+
246
+ <Group gap="xs">
247
+ <Progress
248
+ value={source.percentage}
249
+ size="sm"
250
+ w={compact ? 40 : 60}
251
+ color={getSourceColor(source.source)}
252
+ />
253
+ <Text size="xs" c="dimmed" w={30}>
254
+ {source.percentage}%
255
+ </Text>
256
+ </Group>
257
+ </Group>
258
+ ))}
259
+ </Stack>
260
+ ) : (
261
+ <Text c="dimmed" size="sm">
262
+ No data available
263
+ </Text>
264
+ )}
265
+ </Card.Section>
266
+ </Card>
267
+ )}
268
+ </SimpleGrid>
269
+
270
+ {stats.searchTrends.length > 1 && (
271
+ <Card withBorder>
272
+ <Card.Section p="md">
273
+ <Title order={4} mb="md">
274
+ Recent activity
275
+ </Title>
276
+ <Stack gap="xs" mt="md">
277
+ {stats.searchTrends.slice(-7).map((trend) => {
278
+ const maxCount = Math.max(
279
+ ...stats.searchTrends.map((t) => t.count),
280
+ );
281
+ const percentage =
282
+ maxCount > 0 ? (trend.count / maxCount) * 100 : 0;
283
+
284
+ return (
285
+ <Group key={trend.date} justify="space-between">
286
+ <Text size="xs" c="dimmed" w={80}>
287
+ {new Date(trend.date).toLocaleDateString("en", {
288
+ month: "short",
289
+ day: "numeric",
290
+ })}
291
+ </Text>
292
+
293
+ <Group gap="xs" style={{ flex: 1 }}>
294
+ <Progress
295
+ value={percentage}
296
+ size="sm"
297
+ style={{ flex: 1 }}
298
+ color="blue"
299
+ />
300
+ <Text size="xs" w={20}>
301
+ {trend.count}
302
+ </Text>
303
+ </Group>
304
+ </Group>
305
+ );
306
+ })}
307
+ </Stack>
308
+ </Card.Section>
309
+ </Card>
310
+ )}
311
+ </Stack>
312
+ );
313
+ }
client/components/App/App.tsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Center,
3
+ Container,
4
+ Loader,
5
+ MantineProvider,
6
+ Stack,
7
+ Text,
8
+ } from "@mantine/core";
9
+ import { Route, Switch } from "wouter";
10
+ import "@mantine/core/styles.css";
11
+ import { Notifications } from "@mantine/notifications";
12
+ import { usePubSub } from "create-pubsub/react";
13
+ import { lazy, useEffect, useState } from "react";
14
+ import { addLogEntry } from "@/modules/logEntries";
15
+ import { settingsPubSub } from "@/modules/pubSub";
16
+ import { defaultSettings } from "@/modules/settings";
17
+ import "@mantine/notifications/styles.css";
18
+ import { verifyStoredAccessKey } from "@/modules/accessKey";
19
+ import MainPage from "../Pages/Main/MainPage";
20
+
21
+ const AccessPage = lazy(() => import("../Pages/AccessPage"));
22
+
23
+ /**
24
+ * Main application component with access key validation and routing
25
+ */
26
+ function App() {
27
+ useInitializeSettings();
28
+ const { hasValidatedAccessKey, isCheckingStoredKey, setValidatedAccessKey } =
29
+ useAccessKeyValidation();
30
+
31
+ return (
32
+ <MantineProvider defaultColorScheme="dark">
33
+ {isCheckingStoredKey ? (
34
+ <Container h="100vh">
35
+ <Center h="100vh">
36
+ <Stack align="center">
37
+ <Loader />
38
+ <Text>Verifying access...</Text>
39
+ </Stack>
40
+ </Center>
41
+ </Container>
42
+ ) : (
43
+ <>
44
+ <Notifications />
45
+ <Switch>
46
+ <Route path="/">
47
+ {hasValidatedAccessKey ? (
48
+ <MainPage />
49
+ ) : (
50
+ <AccessPage
51
+ onAccessKeyValid={() => setValidatedAccessKey(true)}
52
+ />
53
+ )}
54
+ </Route>
55
+ </Switch>
56
+ </>
57
+ )}
58
+ </MantineProvider>
59
+ );
60
+ }
61
+
62
+ export default App;
63
+
64
+ /**
65
+ * A custom React hook that initializes the application settings.
66
+ *
67
+ * @returns The initialized settings object.
68
+ *
69
+ * @remarks
70
+ * This hook uses the `usePubSub` hook to access and update the settings state.
71
+ * It initializes the settings by merging the default settings with any existing settings.
72
+ * The initialization is performed once when the component mounts.
73
+ */
74
+ function useInitializeSettings() {
75
+ const [settings, setSettings] = usePubSub(settingsPubSub);
76
+ const [state, setState] = useState({
77
+ settingsInitialized: false,
78
+ });
79
+
80
+ useEffect(() => {
81
+ if (state.settingsInitialized) return;
82
+
83
+ setSettings({ ...defaultSettings, ...settings });
84
+
85
+ setState({ settingsInitialized: true });
86
+
87
+ addLogEntry("Settings initialized");
88
+ }, [settings, setSettings, state.settingsInitialized]);
89
+
90
+ return settings;
91
+ }
92
+
93
+ /**
94
+ * A custom React hook that validates the stored access key on mount.
95
+ *
96
+ * @returns An object containing the validation state and loading state
97
+ */
98
+ function useAccessKeyValidation() {
99
+ const [state, setState] = useState(() => ({
100
+ hasValidatedAccessKey: !VITE_ACCESS_KEYS_ENABLED,
101
+ isCheckingStoredKey: VITE_ACCESS_KEYS_ENABLED,
102
+ }));
103
+
104
+ useEffect(() => {
105
+ if (!VITE_ACCESS_KEYS_ENABLED) return;
106
+
107
+ async function checkStoredAccessKey() {
108
+ const isValid = await verifyStoredAccessKey();
109
+ if (isValid)
110
+ setState((prev) => ({ ...prev, hasValidatedAccessKey: true }));
111
+ setState((prev) => ({ ...prev, isCheckingStoredKey: false }));
112
+ }
113
+
114
+ checkStoredAccessKey();
115
+ }, []);
116
+
117
+ return {
118
+ hasValidatedAccessKey: state.hasValidatedAccessKey,
119
+ isCheckingStoredKey: state.isCheckingStoredKey,
120
+ setValidatedAccessKey: (value: boolean) =>
121
+ setState((prev) => ({ ...prev, hasValidatedAccessKey: value })),
122
+ };
123
+ }
client/components/Logs/LogsModal.tsx ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Alert,
3
+ Button,
4
+ Center,
5
+ CloseButton,
6
+ Group,
7
+ Modal,
8
+ Pagination,
9
+ Table,
10
+ TextInput,
11
+ Tooltip,
12
+ } from "@mantine/core";
13
+ import { IconInfoCircle, IconSearch } from "@tabler/icons-react";
14
+ import { usePubSub } from "create-pubsub/react";
15
+ import { useCallback, useEffect, useMemo, useState } from "react";
16
+ import { logEntriesPubSub } from "@/modules/logEntries";
17
+
18
+ export default function LogsModal({
19
+ opened,
20
+ onClose,
21
+ }: {
22
+ opened: boolean;
23
+ onClose: () => void;
24
+ }) {
25
+ const [logEntries] = usePubSub(logEntriesPubSub);
26
+
27
+ const [page, setPage] = useState(1);
28
+ const [filterText, setFilterText] = useState("");
29
+
30
+ const logEntriesPerPage = 5;
31
+
32
+ const filteredLogEntries = useMemo(() => {
33
+ if (!filterText) return logEntries;
34
+ const lowerCaseFilter = filterText.toLowerCase();
35
+ return logEntries.filter((entry) =>
36
+ entry.message.toLowerCase().includes(lowerCaseFilter),
37
+ );
38
+ }, [logEntries, filterText]);
39
+
40
+ const logEntriesFromCurrentPage = useMemo(
41
+ () =>
42
+ filteredLogEntries.slice(
43
+ (page - 1) * logEntriesPerPage,
44
+ page * logEntriesPerPage,
45
+ ),
46
+ [filteredLogEntries, page],
47
+ );
48
+
49
+ useEffect(() => {
50
+ void filterText;
51
+ setPage(1);
52
+ }, [filterText]);
53
+
54
+ const downloadLogsAsJson = useCallback(() => {
55
+ const jsonString = JSON.stringify(logEntries, null, 2);
56
+ const blob = new Blob([jsonString], { type: "application/json" });
57
+ const url = URL.createObjectURL(blob);
58
+ const link = document.createElement("a");
59
+ link.href = url;
60
+ link.download = "logs.json";
61
+ document.body.appendChild(link);
62
+ link.click();
63
+ document.body.removeChild(link);
64
+ URL.revokeObjectURL(url);
65
+ }, [logEntries]);
66
+
67
+ return (
68
+ <Modal opened={opened} onClose={onClose} size="xl" title="Logs">
69
+ <Alert variant="light" color="blue" icon={<IconInfoCircle />} mb="md">
70
+ <Group justify="space-between" align="center">
71
+ <span>
72
+ This information is stored solely in your browser for personal use.
73
+ It isn't sent automatically and is retained for debugging purposes
74
+ should you need to{" "}
75
+ <a
76
+ href="https://github.com/felladrin/MiniSearch/issues/new?labels=bug&template=bug_report.yml"
77
+ target="_blank"
78
+ rel="noopener noreferrer"
79
+ >
80
+ report a bug
81
+ </a>
82
+ .
83
+ </span>
84
+ <Button onClick={downloadLogsAsJson} size="xs" data-autofocus>
85
+ Download Logs
86
+ </Button>
87
+ </Group>
88
+ </Alert>
89
+ <TextInput
90
+ placeholder="Filter logs..."
91
+ mb="md"
92
+ leftSection={<IconSearch size={16} />}
93
+ value={filterText}
94
+ onChange={(event) => setFilterText(event.currentTarget.value)}
95
+ rightSection={
96
+ filterText ? (
97
+ <Tooltip label="Clear filter" withArrow>
98
+ <CloseButton
99
+ size="sm"
100
+ onClick={() => setFilterText("")}
101
+ aria-label="Clear filter"
102
+ />
103
+ </Tooltip>
104
+ ) : null
105
+ }
106
+ />
107
+ <Table striped highlightOnHover withTableBorder>
108
+ <Table.Thead>
109
+ <Table.Tr>
110
+ <Table.Th style={{ width: 80 }}>Time</Table.Th>
111
+ <Table.Th>Message</Table.Th>
112
+ </Table.Tr>
113
+ </Table.Thead>
114
+ <Table.Tbody>
115
+ {logEntriesFromCurrentPage.map((entry, index) => (
116
+ <Table.Tr key={`${entry.timestamp}-${index}`}>
117
+ <Table.Td>
118
+ {new Date(entry.timestamp).toLocaleTimeString()}
119
+ </Table.Td>
120
+ <Table.Td>{entry.message}</Table.Td>
121
+ </Table.Tr>
122
+ ))}
123
+ </Table.Tbody>
124
+ </Table>
125
+ <Center>
126
+ <Pagination
127
+ total={Math.ceil(filteredLogEntries.length / logEntriesPerPage)}
128
+ value={page}
129
+ onChange={setPage}
130
+ size="sm"
131
+ mt="md"
132
+ />
133
+ </Center>
134
+ </Modal>
135
+ );
136
+ }
client/components/Logs/ShowLogsButton.tsx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button, Center, Loader, Stack, Text } from "@mantine/core";
2
+ import { lazy, Suspense, 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,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ interface AccessPageState {
7
+ accessKey: string;
8
+ error: string;
9
+ }
10
+
11
+ export default function AccessPage({
12
+ onAccessKeyValid,
13
+ }: {
14
+ onAccessKeyValid: () => void;
15
+ }) {
16
+ const [state, setState] = useState<AccessPageState>({
17
+ accessKey: "",
18
+ error: "",
19
+ });
20
+
21
+ const handleSubmit = async (formEvent: FormEvent<HTMLFormElement>) => {
22
+ formEvent.preventDefault();
23
+ setState((prev) => ({ ...prev, error: "" }));
24
+ try {
25
+ const isValid = await validateAccessKey(state.accessKey);
26
+ if (isValid) {
27
+ addLogEntry("Valid access key entered");
28
+ onAccessKeyValid();
29
+ } else {
30
+ setState((prev) => ({ ...prev, error: "Invalid access key" }));
31
+ addLogEntry("Invalid access key attempt");
32
+ }
33
+ } catch (error) {
34
+ setState((prev) => ({ ...prev, error: "Error validating access key" }));
35
+ addLogEntry(`Error validating access key: ${error}`);
36
+ }
37
+ };
38
+
39
+ return (
40
+ <Container size="xs">
41
+ <Stack p="lg" mih="100vh" justify="center">
42
+ <Title order={2} ta="center">
43
+ Access Restricted
44
+ </Title>
45
+ <form onSubmit={handleSubmit}>
46
+ <Stack gap="xs">
47
+ <TextInput
48
+ value={state.accessKey}
49
+ onChange={({ target }) =>
50
+ setState((prev) => ({ ...prev, accessKey: target.value }))
51
+ }
52
+ placeholder="Enter your access key to continue"
53
+ required
54
+ autoFocus
55
+ error={state.error}
56
+ styles={{
57
+ input: {
58
+ textAlign: "center",
59
+ },
60
+ }}
61
+ />
62
+ <Button size="xs" type="submit">
63
+ Submit
64
+ </Button>
65
+ </Stack>
66
+ </form>
67
+ </Stack>
68
+ </Container>
69
+ );
70
+ }
client/components/Pages/Main/MainPage.test.tsx ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ describe("MainPage component logic", () => {
4
+ it("should show results only when query is not empty", () => {
5
+ const isQueryEmpty = (query: string) => query.length === 0;
6
+
7
+ expect(isQueryEmpty("")).toBe(true);
8
+ expect(isQueryEmpty("test query")).toBe(false);
9
+ expect(isQueryEmpty(" ")).toBe(false);
10
+ });
11
+
12
+ it("should determine when to show search results section", () => {
13
+ const shouldShowSearchResults = (
14
+ textSearchState: string,
15
+ imageSearchState: string,
16
+ ) => textSearchState !== "idle" || imageSearchState !== "idle";
17
+
18
+ expect(shouldShowSearchResults("idle", "idle")).toBe(false);
19
+ expect(shouldShowSearchResults("loading", "idle")).toBe(true);
20
+ expect(shouldShowSearchResults("idle", "loading")).toBe(true);
21
+ expect(shouldShowSearchResults("success", "success")).toBe(true);
22
+ });
23
+
24
+ it("should determine when to show AI response section", () => {
25
+ const shouldShowAiResponse = (
26
+ textGenerationState: string,
27
+ showEnableAiResponsePrompt: boolean,
28
+ ) => !showEnableAiResponsePrompt && textGenerationState !== "idle";
29
+
30
+ expect(shouldShowAiResponse("idle", false)).toBe(false);
31
+ expect(shouldShowAiResponse("generating", false)).toBe(true);
32
+ expect(shouldShowAiResponse("generating", true)).toBe(false);
33
+ expect(shouldShowAiResponse("complete", false)).toBe(true);
34
+ });
35
+
36
+ it("should determine when to show enable AI prompt", () => {
37
+ const shouldShowEnablePrompt = (showEnableAiResponsePrompt: boolean) =>
38
+ showEnableAiResponsePrompt;
39
+
40
+ expect(shouldShowEnablePrompt(true)).toBe(true);
41
+ expect(shouldShowEnablePrompt(false)).toBe(false);
42
+ });
43
+
44
+ it("should combine conditions correctly for full page state", () => {
45
+ interface PageState {
46
+ query: string;
47
+ textSearchState: string;
48
+ imageSearchState: string;
49
+ textGenerationState: string;
50
+ showEnableAiResponsePrompt: boolean;
51
+ }
52
+
53
+ const getVisibleSections = (state: PageState) => {
54
+ const isQueryEmpty = state.query.length === 0;
55
+ return {
56
+ showResults:
57
+ !isQueryEmpty &&
58
+ (state.textSearchState !== "idle" ||
59
+ state.imageSearchState !== "idle"),
60
+ showAiResponse:
61
+ !isQueryEmpty &&
62
+ !state.showEnableAiResponsePrompt &&
63
+ state.textGenerationState !== "idle",
64
+ showEnablePrompt: !isQueryEmpty && state.showEnableAiResponsePrompt,
65
+ };
66
+ };
67
+
68
+ expect(
69
+ getVisibleSections({
70
+ query: "",
71
+ textSearchState: "success",
72
+ imageSearchState: "idle",
73
+ textGenerationState: "generating",
74
+ showEnableAiResponsePrompt: false,
75
+ }),
76
+ ).toEqual({
77
+ showResults: false,
78
+ showAiResponse: false,
79
+ showEnablePrompt: false,
80
+ });
81
+
82
+ expect(
83
+ getVisibleSections({
84
+ query: "test",
85
+ textSearchState: "success",
86
+ imageSearchState: "idle",
87
+ textGenerationState: "generating",
88
+ showEnableAiResponsePrompt: false,
89
+ }),
90
+ ).toEqual({
91
+ showResults: true,
92
+ showAiResponse: true,
93
+ showEnablePrompt: false,
94
+ });
95
+
96
+ expect(
97
+ getVisibleSections({
98
+ query: "test",
99
+ textSearchState: "idle",
100
+ imageSearchState: "idle",
101
+ textGenerationState: "idle",
102
+ showEnableAiResponsePrompt: true,
103
+ }),
104
+ ).toEqual({
105
+ showResults: false,
106
+ showAiResponse: false,
107
+ showEnablePrompt: true,
108
+ });
109
+ });
110
+ });
client/components/Pages/Main/MainPage.tsx ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Container, Stack } from "@mantine/core";
2
+ import { usePubSub } from "create-pubsub/react";
3
+ import { lazy, Suspense } from "react";
4
+ import SearchForm from "@/components/Search/Form/SearchForm";
5
+ import {
6
+ imageSearchStatePubSub,
7
+ queryPubSub,
8
+ settingsPubSub,
9
+ textGenerationStatePubSub,
10
+ textSearchStatePubSub,
11
+ } from "@/modules/pubSub";
12
+ import { searchAndRespond } from "@/modules/textGeneration";
13
+ import MenuButton from "./Menu/MenuButton";
14
+
15
+ const AiResponseSection = lazy(
16
+ () => import("@/components/AiResponse/AiResponseSection"),
17
+ );
18
+ const SearchResultsSection = lazy(
19
+ () => import("@/components/Search/Results/SearchResultsSection"),
20
+ );
21
+ const EnableAiResponsePrompt = lazy(
22
+ () => import("@/components/AiResponse/EnableAiResponsePrompt"),
23
+ );
24
+
25
+ export default function MainPage() {
26
+ const [query, updateQuery] = usePubSub(queryPubSub);
27
+ const [textSearchState] = usePubSub(textSearchStatePubSub);
28
+ const [imageSearchState] = usePubSub(imageSearchStatePubSub);
29
+ const [textGenerationState] = usePubSub(textGenerationStatePubSub);
30
+ const [settings, setSettings] = usePubSub(settingsPubSub);
31
+
32
+ const isQueryEmpty = query.length === 0;
33
+
34
+ return (
35
+ <Container>
36
+ <Stack py="md" mih="100vh" justify={isQueryEmpty ? "center" : undefined}>
37
+ <SearchForm
38
+ query={query}
39
+ updateQuery={updateQuery}
40
+ additionalButtons={<MenuButton />}
41
+ />
42
+ {!isQueryEmpty && (
43
+ <>
44
+ {settings.showEnableAiResponsePrompt && (
45
+ <Suspense>
46
+ <EnableAiResponsePrompt
47
+ onAccept={() => {
48
+ setSettings({
49
+ ...settings,
50
+ showEnableAiResponsePrompt: false,
51
+ enableAiResponse: true,
52
+ });
53
+ searchAndRespond();
54
+ }}
55
+ onDecline={() => {
56
+ setSettings({
57
+ ...settings,
58
+ showEnableAiResponsePrompt: false,
59
+ enableAiResponse: false,
60
+ });
61
+ }}
62
+ />
63
+ </Suspense>
64
+ )}
65
+ {!settings.showEnableAiResponsePrompt &&
66
+ textGenerationState !== "idle" && (
67
+ <Suspense>
68
+ <AiResponseSection />
69
+ </Suspense>
70
+ )}
71
+ {(textSearchState !== "idle" || imageSearchState !== "idle") && (
72
+ <Suspense>
73
+ <SearchResultsSection />
74
+ </Suspense>
75
+ )}
76
+ </>
77
+ )}
78
+ </Stack>
79
+ </Container>
80
+ );
81
+ }
client/components/Pages/Main/Menu/AISettings/AISettingsForm.tsx ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Select, Slider, Stack, Switch, Text, TextInput } from "@mantine/core";
2
+ import { useForm } from "@mantine/form";
3
+ import { usePubSub } from "create-pubsub/react";
4
+ import { useMemo } from "react";
5
+ import { settingsPubSub } from "@/modules/pubSub";
6
+ import { defaultSettings, inferenceTypes } from "@/modules/settings";
7
+ import { isWebGPUAvailable } from "@/modules/webGpu";
8
+ import { AIParameterSlider } from "./components/AIParameterSlider";
9
+ import { BrowserSettings } from "./components/BrowserSettings";
10
+ import { HordeSettings } from "./components/HordeSettings";
11
+ import { OpenAISettings } from "./components/OpenAISettings";
12
+ import { SystemPromptInput } from "./components/SystemPromptInput";
13
+ import { useHordeModels } from "./hooks/useHordeModels";
14
+ import { useHordeUserInfo } from "./hooks/useHordeUserInfo";
15
+ import { useOpenAiModels } from "./hooks/useOpenAiModels";
16
+
17
+ export default function AISettingsForm() {
18
+ const [settings, setSettings] = usePubSub(settingsPubSub);
19
+ const { openAiModels, useTextInput } = useOpenAiModels(settings);
20
+ const hordeModels = useHordeModels(settings);
21
+ const hordeUserInfo = useHordeUserInfo(settings);
22
+
23
+ const form = useForm({
24
+ initialValues: settings,
25
+ onValuesChange: setSettings,
26
+ });
27
+
28
+ const inferenceTypeSupportsMinP =
29
+ (form.values.inferenceType === "browser" &&
30
+ (!isWebGPUAvailable || !form.values.enableWebGpu)) ||
31
+ form.values.inferenceType === "horde";
32
+
33
+ const penaltySliderMarks = useMemo(
34
+ () => [
35
+ { value: -2.0, label: "-2.0" },
36
+ { value: 0.0, label: "0" },
37
+ { value: 2.0, label: "2.0" },
38
+ ],
39
+ [],
40
+ );
41
+
42
+ const probabilitySliderMarks = useMemo(
43
+ () =>
44
+ Array.from({ length: 3 }, (_, index) => ({
45
+ value: index / 2,
46
+ label: (index / 2).toString(),
47
+ })),
48
+ [],
49
+ );
50
+
51
+ const searchResultsToConsiderSliderMarks = useMemo(
52
+ () =>
53
+ Array.from({ length: 7 }, (_, index) => ({
54
+ value: index,
55
+ label: index.toString(),
56
+ })),
57
+ [],
58
+ );
59
+
60
+ const temperatureSliderMarks = useMemo(
61
+ () => [
62
+ { value: 0, label: "0" },
63
+ { value: 1, label: "1" },
64
+ { value: 2, label: "2" },
65
+ ],
66
+ [],
67
+ );
68
+
69
+ return (
70
+ <Stack gap="md">
71
+ <Switch
72
+ label="AI Response"
73
+ {...form.getInputProps("enableAiResponse", { type: "checkbox" })}
74
+ labelPosition="left"
75
+ description="Enable or disable AI-generated responses to your queries. When disabled, you'll only see web search results."
76
+ />
77
+
78
+ {form.values.enableAiResponse && (
79
+ <>
80
+ <Stack gap="xs" mb="md">
81
+ <Text size="sm">Search results to consider</Text>
82
+ <Text size="xs" c="dimmed">
83
+ Determines the number of search results to consider when
84
+ generating AI responses. A higher value may enhance accuracy, but
85
+ it will also increase response time.
86
+ </Text>
87
+ <Slider
88
+ {...form.getInputProps("searchResultsToConsider")}
89
+ min={0}
90
+ max={6}
91
+ marks={searchResultsToConsiderSliderMarks}
92
+ />
93
+ </Stack>
94
+
95
+ <Select
96
+ {...form.getInputProps("inferenceType")}
97
+ label="AI Processing Location"
98
+ data={inferenceTypes}
99
+ allowDeselect={false}
100
+ />
101
+
102
+ {form.values.inferenceType === "openai" && (
103
+ <OpenAISettings
104
+ form={form}
105
+ openAiModels={openAiModels}
106
+ useTextInput={useTextInput}
107
+ />
108
+ )}
109
+
110
+ {form.values.inferenceType === "horde" && (
111
+ <HordeSettings
112
+ form={form}
113
+ hordeUserInfo={hordeUserInfo}
114
+ hordeModels={hordeModels}
115
+ />
116
+ )}
117
+
118
+ {form.values.inferenceType === "browser" && (
119
+ <BrowserSettings
120
+ form={form}
121
+ isWebGPUAvailable={isWebGPUAvailable}
122
+ />
123
+ )}
124
+
125
+ <SystemPromptInput form={form} />
126
+
127
+ <AIParameterSlider
128
+ label="Temperature"
129
+ description="Controls randomness in responses. Lower values make responses more focused and deterministic, while higher values make them more creative and diverse."
130
+ defaultValue={defaultSettings.inferenceTemperature}
131
+ {...form.getInputProps("inferenceTemperature")}
132
+ min={0}
133
+ max={2}
134
+ step={0.01}
135
+ marks={temperatureSliderMarks}
136
+ />
137
+
138
+ <AIParameterSlider
139
+ label="Top P"
140
+ description="Controls diversity by limiting cumulative probability of tokens. Lower values make responses more focused, while higher values allow more variety."
141
+ defaultValue={defaultSettings.inferenceTopP}
142
+ {...form.getInputProps("inferenceTopP")}
143
+ min={0}
144
+ max={1}
145
+ step={0.01}
146
+ marks={probabilitySliderMarks}
147
+ />
148
+
149
+ {inferenceTypeSupportsMinP && (
150
+ <AIParameterSlider
151
+ label="Min P"
152
+ description="Sets a minimum probability for token selection. Helps to filter out very unlikely tokens, making responses more coherent."
153
+ defaultValue={defaultSettings.minP}
154
+ {...form.getInputProps("minP")}
155
+ min={0}
156
+ max={1}
157
+ step={0.01}
158
+ marks={probabilitySliderMarks}
159
+ />
160
+ )}
161
+
162
+ <AIParameterSlider
163
+ label="Frequency Penalty"
164
+ description="Reduces repetition by penalizing tokens based on their frequency. Higher values decrease the likelihood of repeating the same information."
165
+ defaultValue={defaultSettings.inferenceFrequencyPenalty}
166
+ {...form.getInputProps("inferenceFrequencyPenalty")}
167
+ min={-2.0}
168
+ max={2.0}
169
+ step={0.01}
170
+ marks={penaltySliderMarks}
171
+ />
172
+
173
+ <AIParameterSlider
174
+ label="Presence Penalty"
175
+ description="Encourages new topics by penalizing tokens that have appeared. Higher values increase the model's likelihood to talk about new topics."
176
+ defaultValue={defaultSettings.inferencePresencePenalty}
177
+ {...form.getInputProps("inferencePresencePenalty")}
178
+ min={-2.0}
179
+ max={2.0}
180
+ step={0.01}
181
+ marks={penaltySliderMarks}
182
+ />
183
+
184
+ <Stack gap="xs" mb="md">
185
+ <Text size="sm">Reasoning Section Parsing</Text>
186
+ <Text size="xs" c="dimmed">
187
+ Configure how the AI's reasoning section is parsed in the
188
+ response.
189
+ </Text>
190
+ <Stack gap="xs">
191
+ <TextInput
192
+ {...form.getInputProps("reasoningStartMarker")}
193
+ description="Start Marker, indicating the start of a reasoning section."
194
+ />
195
+ <TextInput
196
+ {...form.getInputProps("reasoningEndMarker")}
197
+ description="End Marker, indicating the end of a reasoning section."
198
+ />
199
+ </Stack>
200
+ </Stack>
201
+ </>
202
+ )}
203
+ </Stack>
204
+ );
205
+ }
client/components/Pages/Main/Menu/AISettings/components/AIParameterSlider.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Slider, Stack, Text } from "@mantine/core";
2
+ import type { AIParameterSliderProps } from "../types";
3
+
4
+ /**
5
+ * Slider component for AI parameters with label and description
6
+ * @param label - The parameter label
7
+ * @param description - Parameter description text
8
+ * @param defaultValue - Default value for the parameter
9
+ * @param props - Additional props to pass to Slider component
10
+ */
11
+ export const AIParameterSlider = ({
12
+ label,
13
+ description,
14
+ defaultValue,
15
+ ...props
16
+ }: AIParameterSliderProps) => (
17
+ <Stack gap="xs" mb="md">
18
+ <Text size="sm">{label}</Text>
19
+ <Text size="xs" c="dimmed">
20
+ {description} Defaults to {defaultValue}.
21
+ </Text>
22
+ <Slider {...props} />
23
+ </Stack>
24
+ );
client/components/Pages/Main/Menu/AISettings/components/BrowserSettings.tsx ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NumberInput, Skeleton, Switch } from "@mantine/core";
2
+ import type { UseFormReturnType } from "@mantine/form";
3
+ import { lazy, Suspense } from "react";
4
+ import type { defaultSettings } from "@/modules/settings";
5
+
6
+ const WebLlmModelSelect = lazy(
7
+ () => import("@/components/AiResponse/WebLlmModelSelect"),
8
+ );
9
+ const WllamaModelSelect = lazy(
10
+ () => import("@/components/AiResponse/WllamaModelSelect"),
11
+ );
12
+
13
+ /**
14
+ * Props for the BrowserSettings component
15
+ */
16
+ interface BrowserSettingsProps {
17
+ /** Form instance for managing browser AI settings */
18
+ form: UseFormReturnType<typeof defaultSettings>;
19
+ /** Whether WebGPU is available in the current browser */
20
+ isWebGPUAvailable: boolean;
21
+ }
22
+
23
+ /**
24
+ * Component for managing browser-based AI settings.
25
+ * Provides controls for WebGPU/CPU selection, model selection, and CPU thread configuration.
26
+ */
27
+ export const BrowserSettings = ({
28
+ form,
29
+ isWebGPUAvailable,
30
+ }: BrowserSettingsProps) => (
31
+ <>
32
+ {isWebGPUAvailable && (
33
+ <Switch
34
+ label="WebGPU"
35
+ {...form.getInputProps("enableWebGpu", { type: "checkbox" })}
36
+ labelPosition="left"
37
+ description="Enable or disable WebGPU usage. When disabled, the app will use the CPU instead."
38
+ />
39
+ )}
40
+
41
+ {isWebGPUAvailable && form.values.enableWebGpu ? (
42
+ <Suspense fallback={<Skeleton height={50} />}>
43
+ <WebLlmModelSelect
44
+ value={form.values.webLlmModelId}
45
+ onChange={(value: string) =>
46
+ form.setFieldValue("webLlmModelId", value)
47
+ }
48
+ />
49
+ </Suspense>
50
+ ) : (
51
+ <>
52
+ <Suspense fallback={<Skeleton height={50} />}>
53
+ <WllamaModelSelect
54
+ value={form.values.wllamaModelId}
55
+ onChange={(value: string) =>
56
+ form.setFieldValue("wllamaModelId", value)
57
+ }
58
+ />
59
+ </Suspense>
60
+ <NumberInput
61
+ label="CPU threads to use"
62
+ description="Number of threads to use for the AI model. Lower values will use less CPU but may take longer to respond. A value that is too high may cause the app to hang."
63
+ min={1}
64
+ {...form.getInputProps("cpuThreads")}
65
+ />
66
+ </>
67
+ )}
68
+ </>
69
+ );
client/components/Pages/Main/Menu/AISettings/components/HordeSettings.tsx ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Select, TextInput } from "@mantine/core";
2
+ import type { UseFormReturnType } from "@mantine/form";
3
+ import type { defaultSettings } from "@/modules/settings";
4
+ import { aiHordeDefaultApiKey } from "@/modules/textGenerationWithHorde";
5
+ import type { HordeUserInfo, ModelOption } from "../types";
6
+
7
+ /**
8
+ * Props for the HordeSettings component
9
+ */
10
+ interface HordeSettingsProps {
11
+ /** Form instance for managing Horde AI settings */
12
+ form: UseFormReturnType<typeof defaultSettings>;
13
+ /** User information from AI Horde, or null if not logged in */
14
+ hordeUserInfo: HordeUserInfo | null;
15
+ /** Available models from AI Horde */
16
+ hordeModels: ModelOption[];
17
+ }
18
+
19
+ /**
20
+ * Component for managing AI Horde settings.
21
+ * Provides controls for API key input and model selection.
22
+ */
23
+ export const HordeSettings = ({
24
+ form,
25
+ hordeUserInfo,
26
+ hordeModels,
27
+ }: HordeSettingsProps) => (
28
+ <>
29
+ <TextInput
30
+ label="API Key"
31
+ description={
32
+ hordeUserInfo
33
+ ? `Logged in as ${
34
+ hordeUserInfo.username
35
+ } (${hordeUserInfo.kudos.toLocaleString()} kudos)`
36
+ : "By default, it's set to '0000000000', for anonymous access. However, anonymous accounts have the lowest priority when there's too many concurrent requests."
37
+ }
38
+ type="password"
39
+ {...form.getInputProps("hordeApiKey")}
40
+ />
41
+ {form.values.hordeApiKey.length > 0 &&
42
+ form.values.hordeApiKey !== aiHordeDefaultApiKey && (
43
+ <Select
44
+ label="Model"
45
+ description="Optional. When not selected, AI Horde will automatically choose an available model."
46
+ placeholder="Auto-selected"
47
+ data={hordeModels}
48
+ {...form.getInputProps("hordeModel")}
49
+ searchable
50
+ clearable
51
+ />
52
+ )}
53
+ </>
54
+ );
client/components/Pages/Main/Menu/AISettings/components/OpenAISettings.tsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Group, NumberInput, Select, Text, TextInput } from "@mantine/core";
2
+ import type { UseFormReturnType } from "@mantine/form";
3
+ import { IconInfoCircle } from "@tabler/icons-react";
4
+ import { defaultSettings } from "@/modules/settings";
5
+ import type { ModelOption } from "../types";
6
+
7
+ /**
8
+ * Props for the OpenAISettings component
9
+ */
10
+ interface OpenAISettingsProps {
11
+ /** Form instance for managing OpenAI API settings */
12
+ form: UseFormReturnType<typeof defaultSettings>;
13
+ /** Available OpenAI-compatible models */
14
+ openAiModels: ModelOption[];
15
+ /** Whether to use text input instead of select for model */
16
+ useTextInput: boolean;
17
+ }
18
+
19
+ /**
20
+ * Component for managing OpenAI API settings.
21
+ * Provides controls for API base URL, API key, model selection, and context length.
22
+ */
23
+ export const OpenAISettings = ({
24
+ form,
25
+ openAiModels,
26
+ useTextInput,
27
+ }: OpenAISettingsProps) => (
28
+ <>
29
+ <TextInput
30
+ {...form.getInputProps("openAiApiBaseUrl")}
31
+ label="API Base URL"
32
+ placeholder="http://localhost:11434/v1"
33
+ required
34
+ />
35
+ <Group gap="xs">
36
+ <IconInfoCircle size={16} />
37
+ <Text size="xs" c="dimmed" flex={1}>
38
+ You may need to add{" "}
39
+ <em>{`${self.location.protocol}//${self.location.hostname}`}</em> to the
40
+ list of allowed network origins in your API server settings.
41
+ </Text>
42
+ </Group>
43
+ <TextInput
44
+ {...form.getInputProps("openAiApiKey")}
45
+ label="API Key"
46
+ type="password"
47
+ description="Optional, as local API servers usually do not require it."
48
+ />
49
+ {useTextInput ? (
50
+ <TextInput
51
+ {...form.getInputProps("openAiApiModel")}
52
+ label="API Model"
53
+ description="Enter the model identifier"
54
+ />
55
+ ) : (
56
+ <Select
57
+ {...form.getInputProps("openAiApiModel")}
58
+ label="API Model"
59
+ data={openAiModels}
60
+ description="Optional, as some API servers don't provide a model list."
61
+ allowDeselect={false}
62
+ disabled={openAiModels.length === 0}
63
+ searchable
64
+ clearable
65
+ />
66
+ )}
67
+ <NumberInput
68
+ label="Context Length"
69
+ description={`Maximum number of tokens the model can consider. Defaults to ${defaultSettings.openAiContextLength}.`}
70
+ defaultValue={defaultSettings.openAiContextLength}
71
+ {...form.getInputProps("openAiContextLength")}
72
+ step={defaultSettings.openAiContextLength}
73
+ min={defaultSettings.openAiContextLength}
74
+ />
75
+ </>
76
+ );
client/components/Pages/Main/Menu/AISettings/components/SystemPromptInput.tsx ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Text, Textarea } from "@mantine/core";
2
+ import type { UseFormReturnType } from "@mantine/form";
3
+ import { defaultSettings } from "@/modules/settings";
4
+
5
+ /**
6
+ * Props for the SystemPromptInput component
7
+ */
8
+ interface SystemPromptInputProps {
9
+ /** Form instance for managing system prompt settings */
10
+ form: UseFormReturnType<typeof defaultSettings>;
11
+ }
12
+
13
+ /**
14
+ * Component for managing system prompt/instructions for AI.
15
+ * Provides a textarea for customizing AI behavior with examples and restore functionality.
16
+ */
17
+ export const SystemPromptInput = ({ form }: SystemPromptInputProps) => {
18
+ const isUsingCustomInstructions =
19
+ form.values.systemPrompt !== defaultSettings.systemPrompt;
20
+
21
+ /**
22
+ * Handles restoring the default system prompt instructions
23
+ */
24
+ const handleRestoreDefaultInstructions = () => {
25
+ form.setFieldValue("systemPrompt", defaultSettings.systemPrompt);
26
+ };
27
+
28
+ return (
29
+ <Textarea
30
+ size="sm"
31
+ label="Instructions for AI"
32
+ descriptionProps={{
33
+ // @ts-expect-error Mantine v7: `InputDescriptionProps` does not support `component`.
34
+ component: "div",
35
+ }}
36
+ description={
37
+ <>
38
+ <Text size="xs" component="span">
39
+ Customize instructions for the AI to tailor its responses.
40
+ </Text>
41
+ <ul>
42
+ <li>
43
+ Specify preferences
44
+ <ul>
45
+ <li>
46
+ <em>"use simple language"</em>
47
+ </li>
48
+ <li>
49
+ <em>"provide step-by-step explanations"</em>
50
+ </li>
51
+ </ul>
52
+ </li>
53
+ <li>
54
+ Set a response style
55
+ <ul>
56
+ <li>
57
+ <em>"answer in a friendly tone"</em>
58
+ </li>
59
+ <li>
60
+ <em>"write your response in Spanish"</em>
61
+ </li>
62
+ </ul>
63
+ </li>
64
+ <li>
65
+ Provide context about the audience
66
+ <ul>
67
+ <li>
68
+ <em>"you're talking to a high school student"</em>
69
+ </li>
70
+ <li>
71
+ <em>
72
+ "consider that your audience is composed of professionals in
73
+ the field of graphic design"
74
+ </em>
75
+ </li>
76
+ </ul>
77
+ </li>
78
+ </ul>
79
+
80
+ <Text size="xs" component="span">
81
+ The special tag <em>{"{{searchResults}}"}</em> will be replaced with
82
+ the search results, while <em>{"{{currentDate}}"}</em> will be
83
+ replaced with the current date.
84
+ </Text>
85
+
86
+ {isUsingCustomInstructions && (
87
+ <>
88
+ <br />
89
+ <br />
90
+ <Text size="xs" component="span">
91
+ Currently, you're using custom instructions. If you ever need to
92
+ restore the default instructions, you can do so by clicking{" "}
93
+ <Text
94
+ component="span"
95
+ size="xs"
96
+ c="blue"
97
+ style={{ cursor: "pointer" }}
98
+ onClick={handleRestoreDefaultInstructions}
99
+ >
100
+ here
101
+ </Text>
102
+ .
103
+ </Text>
104
+ </>
105
+ )}
106
+ </>
107
+ }
108
+ autosize
109
+ maxRows={10}
110
+ {...form.getInputProps("systemPrompt")}
111
+ />
112
+ );
113
+ };