system HF Staff commited on
Commit
128ecac
ยท
verified ยท
1 Parent(s): e252c34

Sync from felladrin/MiniSearch@951bd60

Browse files
.github/CODE_OF_CONDUCT.md CHANGED
@@ -9,20 +9,18 @@ We pledge to act and interact in ways that contribute to an open, welcoming, div
9
  ## Our Standards
10
 
11
  Examples of behavior that contributes to a positive environment for our community include:
12
-
13
- * Demonstrating empathy and kindness toward other people
14
- * Being respectful of differing opinions, viewpoints, and experiences
15
- * Giving and gracefully accepting constructive feedback
16
- * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
17
- * Focusing on what is best not just for us as individuals, but for the overall community
18
 
19
  Examples of unacceptable behavior include:
20
-
21
- * The use of sexualized language or imagery, and sexual attention or advances of any kind
22
- * Trolling, insulting or derogatory comments, and personal or political attacks
23
- * Public or private harassment
24
- * Publishing others' private information, such as a physical or email address, without their explicit permission
25
- * Other conduct which could reasonably be considered inappropriate in a professional setting
26
 
27
  ## Enforcement Responsibilities
28
 
@@ -37,7 +35,6 @@ This Code of Conduct applies within all community spaces, and also applies when
37
  ## Enforcement
38
 
39
  Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at:
40
-
41
  - GitHub: [@felladrin](https://github.com/felladrin)
42
  - Email: (private contact can be provided upon request)
43
 
 
9
  ## Our Standards
10
 
11
  Examples of behavior that contributes to a positive environment for our community include:
12
+ - Demonstrating empathy and kindness toward other people
13
+ - Being respectful of differing opinions, viewpoints, and experiences
14
+ - Giving and gracefully accepting constructive feedback
15
+ - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
16
+ - Focusing on what is best not just for us as individuals, but for the overall community
 
17
 
18
  Examples of unacceptable behavior include:
19
+ - The use of sexualized language or imagery, and sexual attention or advances of any kind
20
+ - Trolling, insulting or derogatory comments, and personal or political attacks
21
+ - Public or private harassment
22
+ - Publishing others' private information, such as a physical or email address, without their explicit permission
23
+ - Other conduct which could reasonably be considered inappropriate in a professional setting
 
24
 
25
  ## Enforcement Responsibilities
26
 
 
35
  ## Enforcement
36
 
37
  Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at:
 
38
  - GitHub: [@felladrin](https://github.com/felladrin)
39
  - Email: (private contact can be provided upon request)
40
 
.github/CONTRIBUTING.md CHANGED
@@ -22,7 +22,6 @@ docker compose exec development-server npm run lint
22
  ```
23
 
24
  This runs:
25
-
26
  - Biome (formatting/linting)
27
  - TypeScript (type checking)
28
  - knip (dead code detection)
 
22
  ```
23
 
24
  This runs:
 
25
  - Biome (formatting/linting)
26
  - TypeScript (type checking)
27
  - knip (dead code detection)
.github/ISSUE_TEMPLATE/security_vulnerability.yml CHANGED
@@ -9,7 +9,7 @@ body:
9
  attributes:
10
  value: |
11
  # โš ๏ธ IMPORTANT: SECURITY VULNERABILITIES SHOULD NOT BE REPORTED PUBLICLY โš ๏ธ
12
-
13
  This template will create a public issue, but security vulnerabilities must be reported privately through GitHub's [Private Vulnerability Reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability).
14
 
15
  If you accidentally created a public issue, please close it and report privately instead.
 
9
  attributes:
10
  value: |
11
  # โš ๏ธ IMPORTANT: SECURITY VULNERABILITIES SHOULD NOT BE REPORTED PUBLICLY โš ๏ธ
12
+
13
  This template will create a public issue, but security vulnerabilities must be reported privately through GitHub's [Private Vulnerability Reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability).
14
 
15
  If you accidentally created a public issue, please close it and report privately instead.
.github/PULL_REQUEST_TEMPLATE.md CHANGED
@@ -1,8 +1,11 @@
1
  ## Description
 
2
  <!-- What does this PR change, and why? Add ### subsections for anything that needs it. -->
3
 
4
  ## How to test
 
5
  <!-- Numbered steps a reviewer can follow. Say plainly what you could not test. -->
6
 
7
  ## Screenshots
 
8
  <!-- For UI changes. Delete this section if not applicable. -->
 
1
  ## Description
2
+
3
  <!-- What does this PR change, and why? Add ### subsections for anything that needs it. -->
4
 
5
  ## How to test
6
+
7
  <!-- Numbered steps a reviewer can follow. Say plainly what you could not test. -->
8
 
9
  ## Screenshots
10
+
11
  <!-- For UI changes. Delete this section if not applicable. -->
.github/SECURITY.md CHANGED
@@ -5,9 +5,9 @@
5
  Only the latest version of MiniSearch receives security updates.
6
 
7
  | Version | Supported |
8
- |---------|------------|
9
- | Latest | โœ… |
10
- | Older | โŒ |
11
 
12
  ## Reporting a Vulnerability
13
 
@@ -88,13 +88,13 @@ A few of MiniSearch's dependencies ship native code or WebAssembly rather than a
88
 
89
  ### Inventory
90
 
91
- | Module | Type | Where it runs | Why it is trusted |
92
- | --- | --- | --- | --- |
93
- | `onnxruntime-node` | Native (Node addon) | Server-side reranker, CPU only | Microsoft-published ONNX Runtime; exact pin; its install script is **denied** (see below), so nothing is fetched at install time |
94
- | `@wllama/wllama` | WebAssembly | Browser inference | Published wllama project; exact pin; no install script |
95
- | `@huggingface/tokenizers` | Native (Node addon) | Server-side tokenization | Hugging Face-published; exact pin; no install script |
96
- | `hash-wasm` | WebAssembly | argon2id hashing (browser and server) | Widely-used, audited WASM hash library; exact pin; no install script |
97
- | `@moonshine-ai/moonshine-wasm` | WebAssembly | Browser dictation | Exact pin; no install script |
98
 
99
  ### What is verified at install
100
 
@@ -118,7 +118,6 @@ A few of MiniSearch's dependencies ship native code or WebAssembly rather than a
118
  ### Approving a new install script
119
 
120
  Under `strict-allow-scripts`, any package that ships an install script without a recorded decision fails the install. To unblock it, a maintainer must:
121
-
122
  1. Read the script first (`npm view <pkg> scripts`, or the unpacked tarball) and decide whether it is acceptable.
123
  2. Record the decision: `npm approve-scripts <pkg>` writes a version-pinned `"<name>@<version>": true` entry to `package.json#allowScripts` (`allow-scripts-pin` is on); `npm deny-scripts <pkg>` writes `"<name>": false`, denying every version of the package.
124
  3. Update the decision list above in the same PR, with the reasoning.
 
5
  Only the latest version of MiniSearch receives security updates.
6
 
7
  | Version | Supported |
8
+ | ------- | --------- |
9
+ | Latest | โœ… |
10
+ | Older | โŒ |
11
 
12
  ## Reporting a Vulnerability
13
 
 
88
 
89
  ### Inventory
90
 
91
+ | Module | Type | Where it runs | Why it is trusted |
92
+ | ------------------------------ | ------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
93
+ | `onnxruntime-node` | Native (Node addon) | Server-side reranker, CPU only | Microsoft-published ONNX Runtime; exact pin; its install script is **denied** (see below), so nothing is fetched at install time |
94
+ | `@wllama/wllama` | WebAssembly | Browser inference | Published wllama project; exact pin; no install script |
95
+ | `@huggingface/tokenizers` | Native (Node addon) | Server-side tokenization | Hugging Face-published; exact pin; no install script |
96
+ | `hash-wasm` | WebAssembly | argon2id hashing (browser and server) | Widely-used, audited WASM hash library; exact pin; no install script |
97
+ | `@moonshine-ai/moonshine-wasm` | WebAssembly | Browser dictation | Exact pin; no install script |
98
 
99
  ### What is verified at install
100
 
 
118
  ### Approving a new install script
119
 
120
  Under `strict-allow-scripts`, any package that ships an install script without a recorded decision fails the install. To unblock it, a maintainer must:
 
121
  1. Read the script first (`npm view <pkg> scripts`, or the unpacked tarball) and decide whether it is acceptable.
122
  2. Record the decision: `npm approve-scripts <pkg>` writes a version-pinned `"<name>@<version>": true` entry to `package.json#allowScripts` (`allow-scripts-pin` is on); `npm deny-scripts <pkg>` writes `"<name>": false`, denying every version of the package.
123
  3. Update the decision list above in the same PR, with the reasoning.
.github/SUPPORT.md CHANGED
@@ -7,7 +7,6 @@ If you need help with MiniSearch, these are the best support channels.
7
  ### GitHub Discussions
8
 
9
  For questions, feature requests, and general discussion, use [GitHub Discussions](https://github.com/felladrin/MiniSearch/discussions). This is the best place to:
10
-
11
  - Ask how to configure MiniSearch for your use case
12
  - Request new features
13
  - Share ideas and feedback
 
7
  ### GitHub Discussions
8
 
9
  For questions, feature requests, and general discussion, use [GitHub Discussions](https://github.com/felladrin/MiniSearch/discussions). This is the best place to:
 
10
  - Ask how to configure MiniSearch for your use case
11
  - Request new features
12
  - Share ideas and feedback
.github/workflows/publish-docker-image.yml CHANGED
@@ -147,14 +147,14 @@ jobs:
147
  id: record-commit
148
  run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
149
  - name: Log in to the Container registry
150
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
151
  with:
152
  registry: ${{ env.REGISTRY }}
153
  username: ${{ github.actor }}
154
  password: ${{ secrets.GITHUB_TOKEN }}
155
  - name: Extract metadata (tags, labels) for Docker
156
  id: meta
157
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
158
  with:
159
  images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
160
  # `latest` replaces the `main` tag this action emits by default. With
@@ -171,10 +171,10 @@ jobs:
171
  type=raw,value=latest,enable={{is_default_branch}}
172
  type=raw,value=${{ steps.validate-version.outputs.version }}
173
  - name: Set up Docker Buildx
174
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
175
  - name: Build and push Docker Image
176
  id: build-and-push
177
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
178
  with:
179
  context: .
180
  push: true
 
147
  id: record-commit
148
  run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
149
  - name: Log in to the Container registry
150
+ uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
151
  with:
152
  registry: ${{ env.REGISTRY }}
153
  username: ${{ github.actor }}
154
  password: ${{ secrets.GITHUB_TOKEN }}
155
  - name: Extract metadata (tags, labels) for Docker
156
  id: meta
157
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
158
  with:
159
  images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
160
  # `latest` replaces the `main` tag this action emits by default. With
 
171
  type=raw,value=latest,enable={{is_default_branch}}
172
  type=raw,value=${{ steps.validate-version.outputs.version }}
173
  - name: Set up Docker Buildx
174
+ uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
175
  - name: Build and push Docker Image
176
  id: build-and-push
177
+ uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
178
  with:
179
  context: .
180
  push: true
Dockerfile CHANGED
@@ -2,7 +2,7 @@ FROM node:lts-slim
2
 
3
  # Renovate keeps this pin current via the customManager in renovate.json,
4
  # whose regex matches this exact line: keep the quoted 40-character form.
5
- ARG SEARXNG_COMMIT_SHA="1c10abe2d3e4ab7ddcc4e9da1a7d07dc7ca7f8f4"
6
 
7
  ENV PORT=7860
8
  EXPOSE $PORT
 
2
 
3
  # Renovate keeps this pin current via the customManager in renovate.json,
4
  # whose regex matches this exact line: keep the quoted 40-character form.
5
+ ARG SEARXNG_COMMIT_SHA="10db664f14bc34565d4fd6ca6d846b6f932976aa"
6
 
7
  ENV PORT=7860
8
  EXPOSE $PORT
agents.md CHANGED
@@ -17,16 +17,19 @@ This is your navigation hub. Start here, follow the links, and return when you n
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/configuration.md#tuning-the-search-engines`** - Replacing the SearXNG settings file at container start: engine selection, engines blocked from an IP, the `TMPDIR` requirement
27
  - **`docs/security.md`** - Access control, privacy, and security model
28
 
29
  ### Core Functionality
 
30
  - **`docs/api.md`** - HTTP endpoint reference: parameters, responses, and error codes
31
  - **`docs/ai-integration.md`** - AI inference types (Wllama, OpenAI, AI Horde, Internal)
32
  - **`docs/ui-components.md`** - Component architecture and PubSub patterns
@@ -37,6 +40,7 @@ This is your navigation hub. Start here, follow the links, and return when you n
37
  - **`docs/glossary.md`** - Codebase-specific terms and domain concepts
38
 
39
  ### Development
 
40
  - **`docs/development-commands.md`** - Docker, npm, and testing commands
41
  - **`docs/failure-injection.md`** - Graceful-degradation test matrix
42
  - **`docs/coding-conventions.md`** - Style guide and patterns
@@ -72,11 +76,13 @@ Need to:
72
  ## Key Files Reference
73
 
74
  ### Entry Points
 
75
  - `client/index.tsx` - React app initialization
76
  - `vite.config.ts` - Vite dev server with hooks
77
  - `Dockerfile` - Single-stage container build
78
 
79
  ### Business Logic Modules
 
80
  - `client/modules/search.ts` - Search orchestration and caching
81
  - `client/modules/textGeneration.ts` - AI response flow
82
  - `client/modules/pubSub.ts` - All PubSub channels
@@ -84,6 +90,7 @@ Need to:
84
  - `client/modules/history.ts` - Search history database
85
 
86
  ### Server-Side Modules
 
87
  - `server/searchEndpointServerHook.ts` - `/search` endpoints
88
  - `server/pageContentEndpointServerHook.ts` - `/page-content` endpoint
89
  - `server/internalApiEndpointServerHook.ts` - `/inference` proxy
@@ -91,6 +98,7 @@ Need to:
91
  - `server/rerankerService.ts` - Local result reranking
92
 
93
  ### UI Components
 
94
  - `client/components/DictationButton.tsx` - Microphone control shared by the search field and the chat follow-up field
95
  - `client/components/App/` - Application shell with error boundaries
96
  - `client/components/Search/Form/` - Search input and form
@@ -105,6 +113,7 @@ Need to:
105
  - `client/components/Settings/HistorySettings.tsx` - History configuration UI
106
 
107
  ### Client Modules
 
108
  - `client/modules/pubSub.ts` - All PubSub channels for state management
109
  - `client/modules/search.ts` - Search orchestration and IndexedDB caching
110
  - `client/modules/textGeneration.ts` - AI response generation and chat handling
@@ -142,6 +151,7 @@ Need to:
142
  - `client/modules/types.ts` - Shared TypeScript type definitions
143
 
144
  ### Server Modules
 
145
  - `server/searchEndpointServerHook.ts` - `/search/text` and `/search/images` endpoints
146
  - `server/thumbnailEndpointServerHook.ts` - `/thumbnail` endpoint: one search-result thumbnail at a time, token-gated, with an in-process LRU in front of the upstream host
147
  - `server/internalApiEndpointServerHook.ts` - `/inference` proxy to self-hosted API
@@ -168,6 +178,7 @@ Need to:
168
  - `server/downloadFileFromHuggingFaceRepository.ts` - Downloads model files from HuggingFace
169
 
170
  ### Hooks
 
171
  - `client/hooks/useSearchHistory.ts` - Search history management from IndexedDB
172
  - `client/hooks/useHistoryRestore.ts` - Restores full search state from history
173
  - `client/hooks/useDrawerState.ts` - Drawer open/close state with logging
@@ -176,26 +187,34 @@ Need to:
176
  ## Common Tasks Quick Reference
177
 
178
  ### Add a new AI model
179
- 1. Add to `client/modules/wllama.ts`
180
- 2. Update `docs/ai-integration.md`
181
- 3. Update `docs/configuration.md` defaults
 
 
 
 
182
 
183
  ### Add a new setting
 
184
  1. Add to `client/modules/settings.ts` default object
185
  2. Add UI in `client/components/Pages/Main/Menu/`
186
  3. Update `docs/configuration.md` settings table
187
 
188
  ### Modify search behavior
 
189
  1. Edit `client/modules/search.ts`
190
  2. Update `server/webSearchService.ts` if server-side changes needed
191
  3. Check `server/rerankerService.ts` if reranking affected
192
 
193
  ### Fix UI state issues
 
194
  1. Check PubSub channels in `client/modules/pubSub.ts`
195
  2. Verify component subscriptions in `docs/ui-components.md`
196
  3. Ensure proper state updates in business logic modules
197
 
198
  ### Analyze test coverage
 
199
  1. Run `npm run test:coverage` to generate reports
200
  2. Check `coverage/coverage-summary.json` for quick metrics
201
  3. See `docs/development-commands.md` for full coverage analysis guide
@@ -221,11 +240,11 @@ This runs:
221
 
222
  The project is split across three TypeScript configs, and the default one covers less than its name suggests:
223
 
224
- | Config | Covers |
225
- | --- | --- |
226
- | `tsconfig.json` | `client`, `shared`, `test` |
227
  | `tsconfig.node.json` | `vite.config.ts`, `server`, `shared`, `e2e`, `playwright.config.ts` |
228
- | `tsconfig.eval.json` | `eval`, `shared` |
229
 
230
  So `npx tsc --noEmit` reports success while type errors sit in `server/` or `eval/`, and CI then fails on a change that looked clean locally. Only `npm run lint` runs all three configs. Never treat a bare `tsc` as a green typecheck.
231
 
 
17
  ## Repository Map
18
 
19
  ### Getting Started
20
+
21
  - **`docs/quick-start.md`** - Installation, first run, verification
22
  - **`docs/overview.md`** - System architecture and data flow
23
  - **`docs/project-structure.md`** - Directory layout and component organization
24
 
25
  ### Configuration & Setup
26
+
27
  - **`docs/configuration.md`** - Environment variables and settings reference
28
  - **`docs/configuration.md#tuning-the-search-engines`** - Replacing the SearXNG settings file at container start: engine selection, engines blocked from an IP, the `TMPDIR` requirement
29
  - **`docs/security.md`** - Access control, privacy, and security model
30
 
31
  ### Core Functionality
32
+
33
  - **`docs/api.md`** - HTTP endpoint reference: parameters, responses, and error codes
34
  - **`docs/ai-integration.md`** - AI inference types (Wllama, OpenAI, AI Horde, Internal)
35
  - **`docs/ui-components.md`** - Component architecture and PubSub patterns
 
40
  - **`docs/glossary.md`** - Codebase-specific terms and domain concepts
41
 
42
  ### Development
43
+
44
  - **`docs/development-commands.md`** - Docker, npm, and testing commands
45
  - **`docs/failure-injection.md`** - Graceful-degradation test matrix
46
  - **`docs/coding-conventions.md`** - Style guide and patterns
 
76
  ## Key Files Reference
77
 
78
  ### Entry Points
79
+
80
  - `client/index.tsx` - React app initialization
81
  - `vite.config.ts` - Vite dev server with hooks
82
  - `Dockerfile` - Single-stage container build
83
 
84
  ### Business Logic Modules
85
+
86
  - `client/modules/search.ts` - Search orchestration and caching
87
  - `client/modules/textGeneration.ts` - AI response flow
88
  - `client/modules/pubSub.ts` - All PubSub channels
 
90
  - `client/modules/history.ts` - Search history database
91
 
92
  ### Server-Side Modules
93
+
94
  - `server/searchEndpointServerHook.ts` - `/search` endpoints
95
  - `server/pageContentEndpointServerHook.ts` - `/page-content` endpoint
96
  - `server/internalApiEndpointServerHook.ts` - `/inference` proxy
 
98
  - `server/rerankerService.ts` - Local result reranking
99
 
100
  ### UI Components
101
+
102
  - `client/components/DictationButton.tsx` - Microphone control shared by the search field and the chat follow-up field
103
  - `client/components/App/` - Application shell with error boundaries
104
  - `client/components/Search/Form/` - Search input and form
 
113
  - `client/components/Settings/HistorySettings.tsx` - History configuration UI
114
 
115
  ### Client Modules
116
+
117
  - `client/modules/pubSub.ts` - All PubSub channels for state management
118
  - `client/modules/search.ts` - Search orchestration and IndexedDB caching
119
  - `client/modules/textGeneration.ts` - AI response generation and chat handling
 
151
  - `client/modules/types.ts` - Shared TypeScript type definitions
152
 
153
  ### Server Modules
154
+
155
  - `server/searchEndpointServerHook.ts` - `/search/text` and `/search/images` endpoints
156
  - `server/thumbnailEndpointServerHook.ts` - `/thumbnail` endpoint: one search-result thumbnail at a time, token-gated, with an in-process LRU in front of the upstream host
157
  - `server/internalApiEndpointServerHook.ts` - `/inference` proxy to self-hosted API
 
178
  - `server/downloadFileFromHuggingFaceRepository.ts` - Downloads model files from HuggingFace
179
 
180
  ### Hooks
181
+
182
  - `client/hooks/useSearchHistory.ts` - Search history management from IndexedDB
183
  - `client/hooks/useHistoryRestore.ts` - Restores full search state from history
184
  - `client/hooks/useDrawerState.ts` - Drawer open/close state with logging
 
187
  ## Common Tasks Quick Reference
188
 
189
  ### Add a new AI model
190
+
191
+ 1. Check the wasm build can run it: the architecture must exist in `node_modules/@wllama/wllama/esm/wasm/wllama.wasm` (grep the binary for the arch name), and the quant must stay under the ceilings in `docs/ai-integration.md` (shard every file under 2000 MiB; total weights under ~3.7 GB)
192
+ 2. Shard the GGUF (`llama-gguf-split --split --size`, or an equivalent) and publish to a `Felladrin/gguf-sharded-<QUANT>-<Name>` repo
193
+ 3. Add to `client/modules/wllama.ts`; thinking-by-default templates need `chatTemplateKwargs: { enable_thinking: false }`
194
+ 4. Run `node scripts/wllama-catalog-check.cjs` and `npx vitest run client/modules/wllama.test.ts`
195
+ 5. Update `docs/ai-integration.md`
196
+ 6. Update `docs/configuration.md` defaults
197
 
198
  ### Add a new setting
199
+
200
  1. Add to `client/modules/settings.ts` default object
201
  2. Add UI in `client/components/Pages/Main/Menu/`
202
  3. Update `docs/configuration.md` settings table
203
 
204
  ### Modify search behavior
205
+
206
  1. Edit `client/modules/search.ts`
207
  2. Update `server/webSearchService.ts` if server-side changes needed
208
  3. Check `server/rerankerService.ts` if reranking affected
209
 
210
  ### Fix UI state issues
211
+
212
  1. Check PubSub channels in `client/modules/pubSub.ts`
213
  2. Verify component subscriptions in `docs/ui-components.md`
214
  3. Ensure proper state updates in business logic modules
215
 
216
  ### Analyze test coverage
217
+
218
  1. Run `npm run test:coverage` to generate reports
219
  2. Check `coverage/coverage-summary.json` for quick metrics
220
  3. See `docs/development-commands.md` for full coverage analysis guide
 
240
 
241
  The project is split across three TypeScript configs, and the default one covers less than its name suggests:
242
 
243
+ | Config | Covers |
244
+ | -------------------- | ------------------------------------------------------------------- |
245
+ | `tsconfig.json` | `client`, `shared`, `test` |
246
  | `tsconfig.node.json` | `vite.config.ts`, `server`, `shared`, `e2e`, `playwright.config.ts` |
247
+ | `tsconfig.eval.json` | `eval`, `shared` |
248
 
249
  So `npx tsc --noEmit` reports success while type errors sit in `server/` or `eval/`, and CI then fails on a change that looked clean locally. Only `npm run lint` runs all three configs. Never treat a bare `tsc` as a green typecheck.
250
 
changelog.md CHANGED
@@ -3,6 +3,11 @@
3
  Notable changes, grouped by date, most recent first.
4
 
5
 
 
 
 
 
 
6
  ## 2026-10-10
7
 
8
  - The text-search fallback now cascades over Parallel and You.com instead of asking a single provider. On each search the configured providers are tried in a random order, one after the other, until one returns results, under the same 15-second budget, which is split across the providers still untried and not paused so a hanging one cannot starve the rest. A provider that answers HTTP 429 is paused until its `Retry-After` and skipped meanwhile, as You.com's acceptable use policy asks. A single search can now reach more than one provider; each sees the query text and this server's IP address, never the user's. `SEARCH_FALLBACK_PROVIDERS` picks the providers (all of them when empty), and `SEARCH_FALLBACK_PARALLEL_API_KEY` and `SEARCH_FALLBACK_YOUCOM_API_KEY` hold the optional keys. `SEARCH_FALLBACK_PROVIDER` and `SEARCH_FALLBACK_API_KEY` are removed: if either is still set while the fallback is enabled, the server stops at start with an error naming the replacement, and so does a provider key with a control character inside it. `/status` gained per-provider counters under `searches.fallbackProviders`.
 
3
  Notable changes, grouped by date, most recent first.
4
 
5
 
6
+ ## 2026-11-02
7
+
8
+ - Five new browser models joined the Wllama catalog: Spark X2.6 1.7B, Granite 4.2 3B, Qwen 3.5 4B, Gemma 4 E2B and Spark X2.6 4B, each tested end to end against the shipped wasm build with the search-answer prompt. The Gemma 4 and Qwen 3.5 architectures were previously absent from the catalog, and the new entries run on the wasm engine's supported architectures (`gemma4`, `qwen35`, `spark2_5`, `granite`). Models whose chat template thinks by default ship with `chatTemplateKwargs: { enable_thinking: false }`, a new per-model field passed to wllama as `chat_template_kwargs`, because MiniSearch renders only the content channel and a thinking template would otherwise spend the whole token budget on reasoning and answer with nothing. `docs/ai-integration.md` now records the two measured ceilings a candidate must fit (shard files under 2000 MiB, since Chromium's OPFS silently truncates beyond it; total weights under ~3.7 GB, since the wasm linear memory caps at 4 GiB), and `npm run wllama-catalog-check` HEADs every shard of every catalog model on HuggingFace to verify the claimed sizes.
9
+
10
+
11
  ## 2026-10-10
12
 
13
  - The text-search fallback now cascades over Parallel and You.com instead of asking a single provider. On each search the configured providers are tried in a random order, one after the other, until one returns results, under the same 15-second budget, which is split across the providers still untried and not paused so a hanging one cannot starve the rest. A provider that answers HTTP 429 is paused until its `Retry-After` and skipped meanwhile, as You.com's acceptable use policy asks. A single search can now reach more than one provider; each sees the query text and this server's IP address, never the user's. `SEARCH_FALLBACK_PROVIDERS` picks the providers (all of them when empty), and `SEARCH_FALLBACK_PARALLEL_API_KEY` and `SEARCH_FALLBACK_YOUCOM_API_KEY` hold the optional keys. `SEARCH_FALLBACK_PROVIDER` and `SEARCH_FALLBACK_API_KEY` are removed: if either is still set while the fallback is enabled, the server stops at start with an error naming the replacement, and so does a provider key with a control character inside it. `/status` gained per-provider counters under `searches.fallbackProviders`.
client/components/Analytics/SearchStats.tsx CHANGED
@@ -31,6 +31,29 @@ interface StatsData {
31
  recentActivity: SearchEntry[];
32
  }
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  export default function SearchStats({
35
  period = "week",
36
  compact = false,
@@ -175,38 +198,23 @@ export default function SearchStats({
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"
@@ -215,6 +223,7 @@ export default function SearchStats({
215
  ? formatRelativeTime(stats.recentActivity[0].timestamp)
216
  : "Never"
217
  }
 
218
  />
219
  </SimpleGrid>
220
 
 
31
  recentActivity: SearchEntry[];
32
  }
33
 
34
+ function MetricCard({
35
+ title,
36
+ value,
37
+ compact,
38
+ }: {
39
+ title: string;
40
+ value: string | number;
41
+ compact: boolean;
42
+ }) {
43
+ return (
44
+ <Card withBorder p={compact ? "sm" : "md"}>
45
+ <Stack gap="xs" align="flex-start">
46
+ <Text size="xs" tt="uppercase" fw={700} c="dimmed">
47
+ {title}
48
+ </Text>
49
+ <Text fw={700} size={compact ? "lg" : "xl"}>
50
+ {value}
51
+ </Text>
52
+ </Stack>
53
+ </Card>
54
+ );
55
+ }
56
+
57
  export default function SearchStats({
58
  period = "week",
59
  compact = false,
 
198
  );
199
  }
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  return (
202
  <Stack gap={compact ? "sm" : "md"}>
203
  <SimpleGrid cols={compact ? 2 : { base: 1, sm: 2, lg: 4 }}>
204
  <MetricCard
205
  title="Total Searches"
206
  value={stats.totalSearches.toLocaleString()}
207
+ compact={compact}
208
+ />
209
+ <MetricCard
210
+ title="Daily Average"
211
+ value={stats.avgPerDay}
212
+ compact={compact}
213
  />
 
214
  <MetricCard
215
  title="Most Active Hour"
216
  value={formatHour(stats.mostActiveHour)}
217
+ compact={compact}
218
  />
219
  <MetricCard
220
  title="Last Search"
 
223
  ? formatRelativeTime(stats.recentActivity[0].timestamp)
224
  : "Never"
225
  }
226
+ compact={compact}
227
  />
228
  </SimpleGrid>
229
 
client/modules/search.ts CHANGED
@@ -76,7 +76,7 @@ const cacheMetrics = {
76
  return maxValue;
77
  }
78
  const newValue = current + increment;
79
- return newValue > maxValue ? maxValue : newValue;
80
  },
81
  };
82
 
 
76
  return maxValue;
77
  }
78
  const newValue = current + increment;
79
+ return Math.min(newValue, maxValue);
80
  },
81
  };
82
 
client/modules/textGenerationWithWllama.ts CHANGED
@@ -121,6 +121,9 @@ async function generateWithWllama({
121
  stream: true,
122
  abortSignal: abortController.signal,
123
  ...model.getSampling(),
 
 
 
124
  });
125
 
126
  for await (const chunk of stream) {
@@ -179,6 +182,7 @@ async function initializeWllamaInstance(progressCallback?: ProgressCallback) {
179
  suppressNativeLog: true,
180
  allowOffline: true,
181
  },
 
182
  model: {
183
  n_threads: getSettings().cpuThreads,
184
  n_ctx: model.contextSize,
 
121
  stream: true,
122
  abortSignal: abortController.signal,
123
  ...model.getSampling(),
124
+ ...(model.chatTemplateKwargs
125
+ ? { chat_template_kwargs: model.chatTemplateKwargs }
126
+ : {}),
127
  });
128
 
129
  for await (const chunk of stream) {
 
182
  suppressNativeLog: true,
183
  allowOffline: true,
184
  },
185
+ chatTemplateKwargs: model.chatTemplateKwargs,
186
  model: {
187
  n_threads: getSettings().cpuThreads,
188
  n_ctx: model.contextSize,
client/modules/wllama.test.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { wllamaModels } from "./wllama";
3
+
4
+ vi.mock("./logEntries", () => ({ addLogEntry: vi.fn() }));
5
+ vi.mock("./textGenerationUtilities", () => ({ defaultContextSize: 4096 }));
6
+
7
+ const models = Object.entries(wllamaModels);
8
+
9
+ describe("wllamaModels catalog", () => {
10
+ it("lists models in ascending file-size order", () => {
11
+ const sizes = models.map(([, model]) => model.fileSizeInMegabytes);
12
+ expect(sizes).toEqual([...sizes].sort((a, b) => a - b));
13
+ });
14
+
15
+ it("gives every model a unique label", () => {
16
+ const labels = models.map(([, model]) => model.label);
17
+ expect(new Set(labels).size).toBe(labels.length);
18
+ });
19
+
20
+ it("points every model at a sharded Felladrin repository", () => {
21
+ for (const [id, model] of models) {
22
+ expect(model.hfRepoId, id).toMatch(/^Felladrin\/gguf-sharded-/);
23
+ // Chromium's OPFS silently truncates files past 2000 MiB, so every
24
+ // catalog file must be a shard, never a monolithic GGUF.
25
+ expect(model.hfFilePath, id).toMatch(/-\d{5}-of-\d{5}\.gguf$/);
26
+ }
27
+ });
28
+
29
+ it("keeps every model inside the wasm memory ceiling", () => {
30
+ // The wllama wasm build caps linear memory at 4 GiB. Models at 3.5 GB
31
+ // and above were measured to OOM during buffer reserve (gemma4 quants),
32
+ // so the catalog stays clearly below that line.
33
+ for (const [id, model] of models) {
34
+ expect(model.fileSizeInMegabytes, id).toBeLessThan(3700);
35
+ expect(model.fileSizeInMegabytes, id).toBeGreaterThan(0);
36
+ }
37
+ });
38
+
39
+ it("switches thinking-by-default chat templates off", () => {
40
+ // MiniSearch renders only the content channel. A template that thinks
41
+ // by default spends the whole token budget on reasoning_content and the
42
+ // user sees an empty answer, so any model passing template kwargs must
43
+ // be turning thinking off.
44
+ for (const [id, model] of models) {
45
+ if (model.chatTemplateKwargs) {
46
+ expect(model.chatTemplateKwargs, id).toEqual({
47
+ enable_thinking: false,
48
+ });
49
+ }
50
+ }
51
+ });
52
+
53
+ it("uses the shared default context size for every model", () => {
54
+ for (const [id, model] of models) {
55
+ expect(model.contextSize, id).toBe(4096);
56
+ }
57
+ });
58
+ });
client/modules/wllama.ts CHANGED
@@ -12,6 +12,7 @@ import { defaultContextSize } from "./textGenerationUtilities";
12
  interface WllamaInitConfig {
13
  wllama?: WllamaConfig;
14
  model?: LoadModelParams & DownloadOptions;
 
15
  }
16
 
17
  async function createWllamaInstance(config?: WllamaConfig): Promise<Wllama> {
@@ -64,6 +65,9 @@ export async function initializeWllama(
64
  },
65
  ],
66
  max_tokens: 1,
 
 
 
67
  });
68
 
69
  const hasWarmupSucceeded = !!warmupResponse.choices[0]?.message?.content;
@@ -122,6 +126,13 @@ export interface WllamaModel {
122
  readonly shouldIncludeUrlsOnPrompt: boolean;
123
  readonly stopStrings?: string[];
124
  readonly flash_attn?: boolean;
 
 
 
 
 
 
 
125
  getSampling: () => Omit<SamplingParams, "logit_bias">;
126
  }
127
 
@@ -320,6 +331,14 @@ export const wllamaModels: Readonly<Record<string, WllamaModel>> = {
320
  hfFilePath: "model.shard-00001-of-00007.gguf",
321
  fileSizeInMegabytes: 1740,
322
  },
 
 
 
 
 
 
 
 
323
  "smollm3-3b": {
324
  ...createDefaultModelConfig(),
325
  label: "SmolLM 3 3B",
@@ -348,6 +367,14 @@ export const wllamaModels: Readonly<Record<string, WllamaModel>> = {
348
  hfFilePath: "model.shard-00001-of-00007.gguf",
349
  fileSizeInMegabytes: 1930,
350
  },
 
 
 
 
 
 
 
 
351
  "phi-3.5-mini-3.8b": {
352
  ...createDefaultModelConfig(),
353
  label: "Phi 3.5 Mini 3.8B",
@@ -369,6 +396,22 @@ export const wllamaModels: Readonly<Record<string, WllamaModel>> = {
369
  hfFilePath: "model.shard-00001-of-00008.gguf",
370
  fileSizeInMegabytes: 2380,
371
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  "phi-4-mini-reasoning": {
373
  ...createDefaultModelConfig(),
374
  label: "Phi 4 Mini Reasoning 3.8B",
@@ -376,4 +419,12 @@ export const wllamaModels: Readonly<Record<string, WllamaModel>> = {
376
  hfFilePath: "model.shard-00001-of-00005.gguf",
377
  fileSizeInMegabytes: 2460,
378
  },
 
 
 
 
 
 
 
 
379
  };
 
12
  interface WllamaInitConfig {
13
  wllama?: WllamaConfig;
14
  model?: LoadModelParams & DownloadOptions;
15
+ chatTemplateKwargs?: Record<string, unknown>;
16
  }
17
 
18
  async function createWllamaInstance(config?: WllamaConfig): Promise<Wllama> {
 
65
  },
66
  ],
67
  max_tokens: 1,
68
+ ...(config?.chatTemplateKwargs
69
+ ? { chat_template_kwargs: config.chatTemplateKwargs }
70
+ : {}),
71
  });
72
 
73
  const hasWarmupSucceeded = !!warmupResponse.choices[0]?.message?.content;
 
126
  readonly shouldIncludeUrlsOnPrompt: boolean;
127
  readonly stopStrings?: string[];
128
  readonly flash_attn?: boolean;
129
+ /**
130
+ * Extra variables handed to the model's chat template on every completion
131
+ * (wllama's `chat_template_kwargs`). Used to switch off thinking-by-default
132
+ * templates: MiniSearch renders only the content channel, so a model that
133
+ * spends its whole budget on `reasoning_content` would answer with nothing.
134
+ */
135
+ readonly chatTemplateKwargs?: Record<string, unknown>;
136
  getSampling: () => Omit<SamplingParams, "logit_bias">;
137
  }
138
 
 
331
  hfFilePath: "model.shard-00001-of-00007.gguf",
332
  fileSizeInMegabytes: 1740,
333
  },
334
+ "spark-x2.6-1.7b": {
335
+ ...createDefaultModelConfig(),
336
+ label: "Spark X2.6 1.7B",
337
+ hfRepoId: "Felladrin/gguf-sharded-Q4_K_M-Spark-X2.6-1.7B",
338
+ hfFilePath: "Spark-X2.6-1.7B-Q4_K_M-00001-of-00004.gguf",
339
+ fileSizeInMegabytes: 1769,
340
+ chatTemplateKwargs: { enable_thinking: false },
341
+ },
342
  "smollm3-3b": {
343
  ...createDefaultModelConfig(),
344
  label: "SmolLM 3 3B",
 
367
  hfFilePath: "model.shard-00001-of-00007.gguf",
368
  fileSizeInMegabytes: 1930,
369
  },
370
+ "granite-4.2-3b": {
371
+ ...createDefaultModelConfig(),
372
+ label: "Granite 4.2 3B",
373
+ hfRepoId: "Felladrin/gguf-sharded-Q4_K_S-granite-4.2-3b",
374
+ hfFilePath: "granite-4.2-3b-Q4_K_S-00001-of-00005.gguf",
375
+ fileSizeInMegabytes: 2143,
376
+ chatTemplateKwargs: { enable_thinking: false },
377
+ },
378
  "phi-3.5-mini-3.8b": {
379
  ...createDefaultModelConfig(),
380
  label: "Phi 3.5 Mini 3.8B",
 
396
  hfFilePath: "model.shard-00001-of-00008.gguf",
397
  fileSizeInMegabytes: 2380,
398
  },
399
+ "qwen-3.5-4b": {
400
+ ...createDefaultModelConfig(),
401
+ label: "Qwen 3.5 4B",
402
+ hfRepoId: "Felladrin/gguf-sharded-UD-Q3_K_XL-Qwen3.5-4B",
403
+ hfFilePath: "Qwen3.5-4B-UD-Q3_K_XL-00001-of-00005.gguf",
404
+ fileSizeInMegabytes: 2436,
405
+ chatTemplateKwargs: { enable_thinking: false },
406
+ },
407
+ "gemma-4-e2b": {
408
+ ...createDefaultModelConfig(),
409
+ label: "Gemma 4 E2B",
410
+ hfRepoId: "Felladrin/gguf-sharded-Q3_K_S-gemma-4-e2b-it",
411
+ hfFilePath: "gemma-4-e2b-it-Q3_K_S-00001-of-00005.gguf",
412
+ fileSizeInMegabytes: 2446,
413
+ chatTemplateKwargs: { enable_thinking: false },
414
+ },
415
  "phi-4-mini-reasoning": {
416
  ...createDefaultModelConfig(),
417
  label: "Phi 4 Mini Reasoning 3.8B",
 
419
  hfFilePath: "model.shard-00001-of-00005.gguf",
420
  fileSizeInMegabytes: 2460,
421
  },
422
+ "spark-x2.6-4b": {
423
+ ...createDefaultModelConfig(),
424
+ label: "Spark X2.6 4B",
425
+ hfRepoId: "Felladrin/gguf-sharded-Q4_K_M-Spark-X2.6-4B",
426
+ hfFilePath: "Spark-X2.6-4B-Q4_K_M-00001-of-00007.gguf",
427
+ fileSizeInMegabytes: 3543,
428
+ chatTemplateKwargs: { enable_thinking: false },
429
+ },
430
  };
docs/ai-integration.md CHANGED
@@ -4,12 +4,12 @@ MiniSearch supports four AI inference backends, each with different trade-offs b
4
 
5
  ## Inference Types Overview
6
 
7
- | Type | Privacy | Speed | Setup | Best For |
8
- |------|---------|-------|-------|----------|
9
- | **Browser** (Wllama) | Maximum (no data leaves device) | Fast (WebGPU) / Slow (CPU) | None | Personal use, privacy-critical scenarios |
10
- | **OpenAI** | Low (data sent to OpenAI) | Very Fast | API Key | Maximum quality, convenience |
11
- | **AI Horde** | Medium (distributed volunteers) | Variable | Anonymous | Free GPU access, no setup |
12
- | **Internal** | High (your infrastructure) | Depends on hardware | Self-hosted API | Teams, compliance requirements |
13
 
14
  ## Browser-Based Inference
15
 
@@ -32,7 +32,15 @@ Uses `@wllama/wllama` for browser-based inference. Automatically uses WebGPU whe
32
  5. Streams tokens via the OAI-compatible `createChatCompletion` API
33
 
34
  **Pre-configured Models:**
35
- 30+ stored at `Felladrin/gguf-sharded-*` on HuggingFace. MiniSearch currently ships 35 pre-configured models, ranging from SmolLM 2 135M (~100MB) up to Phi 4 Mini Reasoning 3.8B (~2.4GB). The full, authoritative list with HuggingFace repo IDs and sizes lives in `client/modules/wllama.ts` (`wllamaModels`) and is rendered in the Settings dropdown; refer to that file rather than a static table here, since models are added/swapped frequently.
 
 
 
 
 
 
 
 
36
 
37
  **Configuration:**
38
  - Settings โ†’ Inference Type: `Browser`
@@ -232,17 +240,17 @@ Save to history database
232
 
233
  `textGenerationStatePubSub` drives the AI response UI through these states:
234
 
235
- | State | Description |
236
- |-------|-------------|
237
- | `idle` | No response yet, waiting for user input |
238
  | `awaitingModelDownloadAllowance` | Browser inference only: waiting for user consent to download the model (when `allowAiModelDownload` is off) |
239
- | `loadingModel` | Downloading/loading the AI model (browser inference only) |
240
- | `awaitingSearchResults` | Waiting for search results before generating |
241
- | `preparingToGenerate` | Search results ready, request being sent to the inference backend |
242
- | `generating` | Streaming response tokens |
243
- | `interrupted` | User clicked Stop; generation aborts without overwriting the partial response |
244
- | `completed` | Full response received |
245
- | `failed` | Error occurred during generation |
246
 
247
  For non-browser inference types (`openai`, `internal`, `horde`), the state skips `loadingModel` and `awaitingModelDownloadAllowance`, going from `awaitingSearchResults` straight to `preparingToGenerate` then `generating` or `failed`.
248
 
@@ -283,7 +291,6 @@ For browser inference, the system first loads the model from HuggingFace with a
283
  ### Rolling Summaries
284
 
285
  When conversation exceeds token budget:
286
-
287
  1. **Detect Overflow:** Current tokens > 3072
288
  2. **Generate Summary:** Call LLM with 800-token limit
289
  3. **Store Summary:** Save in conversationSummaryPubSub
@@ -300,10 +307,10 @@ and user intent. Be concise but informative.
300
 
301
  The "Listen to response" button reads the answer aloud. Two engines back it:
302
 
303
- | Engine | Setting value | Voice source | Works offline |
304
- | --- | --- | --- | --- |
305
  | Local neural | `local` (default) | Piper (VITS) models run in the browser through `@diffusionstudio/vits-web` | No: only the voice model is stored in OPFS, so the voice index, the ONNX runtime and the phonemizer are fetched again |
306
- | System | `system` | `speechSynthesis`, whatever the OS provides | Yes |
307
 
308
  ### Flow
309
 
@@ -384,18 +391,22 @@ Wllama models are sharded (split into chunks):
384
  ## Best Practices
385
 
386
  ### For Privacy-Critical Use
 
387
  - Use Browser inference (Wllama)
388
  - Set `historyRetentionDays: 0` (no persistence)
389
 
390
  ### For Maximum Quality
 
391
  - Use OpenAI GPT-4 or Internal API with large model
392
  - Adjust temperature: 0.5-0.7 for factual, 0.8-1.0 for creative
393
 
394
  ### For Cost Efficiency
 
395
  - Use AI Horde (free) or Browser inference - Wllama (one-time download)
396
  - Limit `inferenceMaxTokens: 2048`
397
 
398
  ### For Teams/Enterprise
 
399
  - Deploy Internal API with vLLM
400
  - Set `ACCESS_KEYS` for access control
401
  - Enable server-side logging
 
4
 
5
  ## Inference Types Overview
6
 
7
+ | Type | Privacy | Speed | Setup | Best For |
8
+ | -------------------- | ------------------------------- | -------------------------- | --------------- | ---------------------------------------- |
9
+ | **Browser** (Wllama) | Maximum (no data leaves device) | Fast (WebGPU) / Slow (CPU) | None | Personal use, privacy-critical scenarios |
10
+ | **OpenAI** | Low (data sent to OpenAI) | Very Fast | API Key | Maximum quality, convenience |
11
+ | **AI Horde** | Medium (distributed volunteers) | Variable | Anonymous | Free GPU access, no setup |
12
+ | **Internal** | High (your infrastructure) | Depends on hardware | Self-hosted API | Teams, compliance requirements |
13
 
14
  ## Browser-Based Inference
15
 
 
32
  5. Streams tokens via the OAI-compatible `createChatCompletion` API
33
 
34
  **Pre-configured Models:**
35
+ 30+ stored at `Felladrin/gguf-sharded-*` on HuggingFace. MiniSearch currently ships 39 pre-configured models, ranging from SmolLM 2 135M (~100MB) up to Spark X2.6 4B (~3.5GB). The full, authoritative list with HuggingFace repo IDs and sizes lives in `client/modules/wllama.ts` (`wllamaModels`) and is rendered in the Settings dropdown; refer to that file rather than a static table here, since models are added/swapped frequently.
36
+
37
+ Two constraints decide what can join that list, both measured against the shipped wasm build:
38
+ - **Every model file must be a shard.** Chromium's OPFS storage silently truncates files past 2000 MiB, so a monolithic GGUF above that size arrives corrupted in the browser. Shards also keep progressive download working.
39
+ - **The wasm linear memory ceiling is 4 GiB.** Models at ~3.5 GB and above OOM during buffer reserve, so the practical catalog ceiling is around 3.7 GB of weights.
40
+
41
+ Models whose chat template thinks by default (Granite 4.2, Gemma 4, Spark X2.6, Qwen 3.5) would spend the whole token budget on the reasoning channel, which MiniSearch does not render. They are configured with `chatTemplateKwargs: { enable_thinking: false }`, passed to wllama as `chat_template_kwargs`.
42
+
43
+ `node scripts/wllama-catalog-check.cjs` HEADs every shard of every catalog model on HuggingFace and verifies the claimed sizes; run it after adding or re-sharding a model.
44
 
45
  **Configuration:**
46
  - Settings โ†’ Inference Type: `Browser`
 
240
 
241
  `textGenerationStatePubSub` drives the AI response UI through these states:
242
 
243
+ | State | Description |
244
+ | -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
245
+ | `idle` | No response yet, waiting for user input |
246
  | `awaitingModelDownloadAllowance` | Browser inference only: waiting for user consent to download the model (when `allowAiModelDownload` is off) |
247
+ | `loadingModel` | Downloading/loading the AI model (browser inference only) |
248
+ | `awaitingSearchResults` | Waiting for search results before generating |
249
+ | `preparingToGenerate` | Search results ready, request being sent to the inference backend |
250
+ | `generating` | Streaming response tokens |
251
+ | `interrupted` | User clicked Stop; generation aborts without overwriting the partial response |
252
+ | `completed` | Full response received |
253
+ | `failed` | Error occurred during generation |
254
 
255
  For non-browser inference types (`openai`, `internal`, `horde`), the state skips `loadingModel` and `awaitingModelDownloadAllowance`, going from `awaitingSearchResults` straight to `preparingToGenerate` then `generating` or `failed`.
256
 
 
291
  ### Rolling Summaries
292
 
293
  When conversation exceeds token budget:
 
294
  1. **Detect Overflow:** Current tokens > 3072
295
  2. **Generate Summary:** Call LLM with 800-token limit
296
  3. **Store Summary:** Save in conversationSummaryPubSub
 
307
 
308
  The "Listen to response" button reads the answer aloud. Two engines back it:
309
 
310
+ | Engine | Setting value | Voice source | Works offline |
311
+ | ------------ | ----------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
312
  | Local neural | `local` (default) | Piper (VITS) models run in the browser through `@diffusionstudio/vits-web` | No: only the voice model is stored in OPFS, so the voice index, the ONNX runtime and the phonemizer are fetched again |
313
+ | System | `system` | `speechSynthesis`, whatever the OS provides | Yes |
314
 
315
  ### Flow
316
 
 
391
  ## Best Practices
392
 
393
  ### For Privacy-Critical Use
394
+
395
  - Use Browser inference (Wllama)
396
  - Set `historyRetentionDays: 0` (no persistence)
397
 
398
  ### For Maximum Quality
399
+
400
  - Use OpenAI GPT-4 or Internal API with large model
401
  - Adjust temperature: 0.5-0.7 for factual, 0.8-1.0 for creative
402
 
403
  ### For Cost Efficiency
404
+
405
  - Use AI Horde (free) or Browser inference - Wllama (one-time download)
406
  - Limit `inferenceMaxTokens: 2048`
407
 
408
  ### For Teams/Enterprise
409
+
410
  - Deploy Internal API with vLLM
411
  - Set `ACCESS_KEYS` for access control
412
  - Enable server-side logging
docs/api.md CHANGED
@@ -11,17 +11,17 @@ documented here because the owner of an instance can reach them, and because
11
  reading middleware should not be the only way to learn what a request has to
12
  carry.
13
 
14
- | Method | Path | Token | Rate limit | Purpose |
15
- | --- | --- | --- | --- | --- |
16
- | `GET` | `/search/text` | required | shared | Text results from SearXNG, reranked |
17
- | `GET` | `/search/images` | required | shared | Image results from SearXNG, reranked |
18
- | `GET` | `/page-content` | required | shared | Readable text from result pages, for answer grounding |
19
- | `GET` | `/thumbnail` | required | own budget | One image-result thumbnail, fetched server-side |
20
- | `POST` | `/inference` | required | shared | Streaming chat completion from the internal API |
21
- | `GET` | `/status` | none | none | Uptime, counters, and service health |
22
- | `GET` | `/api/config` | none | none | Runtime client configuration, including the search token |
23
- | `POST` | `/api/validate-access-key` | none | shared | Access key check |
24
- | `GET` | `/dictation-models/<version>/<file>` | none | none | Speech-to-text model files, served from the instance |
25
 
26
  A path that matches no hook falls through to Vite's static handler, which is
27
  also what a method mismatch does on `/api/config` and `/api/validate-access-key`:
@@ -66,11 +66,11 @@ unauthenticated caller still costs a rate-limit point. The exception is
66
  those two rejections need no token and spend no rate-limit point. The failures
67
  below are the same on every token-gated endpoint:
68
 
69
- | Status | Body | When |
70
- | --- | --- | --- |
71
- | `429` | `{"error":"Too many requests."}` | The bucket for this client is empty |
72
- | `400` | `{"error":"Missing token."}` | No `token` parameter |
73
- | `401` | `{"error":"Invalid token."}` | The hash does not verify against this server's token |
74
 
75
  A 401 that persists across reloads usually means two processes hold different
76
  tokens: the server logs that once, on the way out.
@@ -101,10 +101,10 @@ front of it.
101
  `server/verifyTokenAndRateLimit.ts` keeps two in-memory buckets, both keyed by
102
  client IP:
103
 
104
- | Bucket | Budget | Consumed by |
105
- | --- | --- | --- |
106
- | shared | 10 requests / 10 s | `/search/text`, `/search/images`, `/page-content`, `/inference`, `/api/validate-access-key` |
107
- | thumbnail | 60 requests / 10 s | `/thumbnail` |
108
 
109
  `/thumbnail` has its own budget because one image search fans out into up to 30
110
  tile loads, which would otherwise consume a user's whole search budget. The
@@ -122,18 +122,16 @@ identity.
122
 
123
  Query parameters:
124
 
125
- | Name | Required | Description |
126
- | --- | --- | --- |
127
- | `q` | yes | Search query, trimmed, 1 to 2000 characters |
128
- | `token` | yes | Search token hash |
129
- | `limit` | no | How many of SearXNG's results to consider, default and maximum 30; a value that is not a positive integer falls back to the default. SearXNG is not sent this number: it caps a single SearXNG response after duplicate URLs are dropped, and the score filter below can return fewer |
130
 
131
  Responds with a JSON array of tuples, in the order the client renders them:
132
 
133
  ```json
134
- [
135
- ["Result title", "Snippet text", "https://example.com/page", 3.7]
136
- ]
137
  ```
138
 
139
  Between SearXNG and the response the server drops duplicate URLs, keeps the
@@ -167,12 +165,12 @@ optional.
167
 
168
  Failures:
169
 
170
- | Status | Body | When |
171
- | --- | --- | --- |
172
- | `400` | `{"error":"Missing query parameter"}` | `q` missing or blank |
173
- | `400` | `{"error":"Query parameter must not exceed 2000 characters"}` | `q` too long |
174
- | `502` | `{"error":"Search service unavailable"}` | SearXNG failed and the text-search fallback was off or failed too, so an outage is distinguishable from a search with no matches |
175
- | `500` | `{"error":"Internal server error"}` | Anything else |
176
 
177
  With `SEARCH_FALLBACK_ENABLED` on (see `docs/configuration.md`), a text search
178
  whose SearXNG attempt failed is retried against the configured fallback
@@ -236,11 +234,11 @@ is SSRF-guarded, byte-capped and timed out per page
236
 
237
  The path is matched exactly, so `/page-content/anything` falls through.
238
 
239
- | Name | Required | Description |
240
- | --- | --- | --- |
241
- | `q` | yes | Query the passages are ranked against, 1 to 2000 characters |
242
- | `token` | yes | Search token hash |
243
- | `url` | yes | Page to read, `http` or `https`, up to 2048 characters. Repeat for more; at most 6 per request, counted before duplicates are removed, so 7 URLs are refused even when two of them are the same |
244
 
245
  Responds with an object keyed by URL:
246
 
@@ -266,10 +264,10 @@ answered `Invalid URL parameter`, the same as any other unusable URL.
266
  Fetches one image-result thumbnail server-side, so the browser never requests a
267
  search-result URL directly.
268
 
269
- | Name | Required | Description |
270
- | --- | --- | --- |
271
- | `u` | yes | Thumbnail URL, up to 2048 characters |
272
- | `token` | yes | Search token hash |
273
 
274
  A hit responds with the image bytes and the upstream's content type, restricted
275
  to `image/avif`, `image/bmp`, `image/gif`, `image/jpeg`, `image/png`,
@@ -282,13 +280,13 @@ The fetch is bounded by a 3 s deadline covering DNS and every hop, at most 3
282
  redirects, each of them re-validated, and 500 KB of body. Successful fetches are
283
  held in an in-process LRU (100 entries, 50 MB); failures never are.
284
 
285
- | Status | Body | When |
286
- | --- | --- | --- |
287
- | `400` | `{"error":"Missing thumbnail URL"}` | `u` missing |
288
- | `400` | `{"error":"Thumbnail URL too long"}` | `u` over 2048 characters |
289
- | `403` | `{"error":"Refusing to fetch a thumbnail from a non-public or unresolvable address"}` | The host is in private space, does not resolve, or `u` is not a parseable `http`/`https` URL. `/page-content` answers `400` for that last case; this endpoint does not distinguish it |
290
- | `502` | `{"error":"Thumbnail could not be fetched"}` | Upstream failed, timed out, redirected more than 3 times, answered with a type outside the list, or sent an empty body |
291
- | `500` | `{"error":"Internal server error"}` | Anything the hook itself threw, caught so Vite's connect stack does not see an unhandled rejection |
292
 
293
  Error responses carry `Cache-Control: no-store`, since neither a refusal nor an
294
  upstream failure is a stable property of the URL.
@@ -341,17 +339,17 @@ and `Connection: keep-alive`.
341
 
342
  Failures before the stream starts:
343
 
344
- | Status | Body | When |
345
- | --- | --- | --- |
346
- | `405` | `{"error":"Method Not Allowed"}` | Not a `POST`; the response carries `Allow: POST`. Checked before the token, so it needs no token and spends no rate-limit point |
347
- | `415` | `{"error":"Unsupported Media Type"}` | `Content-Type` is not JSON; also checked before the token |
348
- | `400` | `{"error":"Invalid request body"}` or `{"error":"Invalid request body: <field> <message>"}` | Unparseable or schema-invalid body |
349
- | `413` | `{"error":"Request body too large"}` | Body over 1 MiB |
350
- | `500` | `{"error":"OpenAI API configuration is missing"}` | `INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL` or `_API_KEY` unset |
351
- | `500` | `{"error":"Failed to fetch available models"}` | No model configured and the provider's listing failed |
352
- | `500` | `{"error":"No model available"}` | The listing succeeded but was empty |
353
- | `503` | `{"error":"Service unavailable - all models failed","lastError":"..."}` | Every attempt failed before the first token |
354
- | `500` | `{"error":"Internal server error","message":"..."}` | Anything else the hook threw |
355
 
356
  ### `GET /status`
357
 
@@ -425,11 +423,11 @@ The body is capped at 4 KiB, which one encoded hash is nowhere near. Past that
425
  the answer is a `413` sent while the caller is still uploading, so it carries
426
  `Connection: close` and the socket is dropped once it has flushed.
427
 
428
- | Status | Body | When |
429
- | --- | --- | --- |
430
- | `429` | `{"error":"Too many requests."}` | Rate limited, kept distinct from a wrong key so the UI can say "try again" |
431
- | `400` | `{"valid":false,"error":"Invalid request"}` | Body is not JSON |
432
- | `413` | `{"error":"Request body too large"}` | Body over 4 KiB |
433
 
434
  ### `GET /dictation-models/<version>/<file>`
435
 
@@ -449,10 +447,10 @@ named after the version. Concurrent first requests share one transfer. Responses
449
  are streamed and carry `Cache-Control: public, max-age=31536000, immutable`,
450
  which the version segment makes safe.
451
 
452
- | Status | Body | When |
453
- | --- | --- | --- |
454
- | `404` | `Unknown dictation model file` | The filename is not one of the seven |
455
- | `502` | `Could not serve <file>: <reason>` | The upstream failed, sent no body, exceeded the 64 MiB cap, or the bytes did not match the pinned digest |
456
 
457
  A failure once the body has started cannot be reported, because the headers are
458
  already gone; the response is ended rather than having an error appended to a
 
11
  reading middleware should not be the only way to learn what a request has to
12
  carry.
13
 
14
+ | Method | Path | Token | Rate limit | Purpose |
15
+ | ------ | ------------------------------------ | -------- | ---------- | -------------------------------------------------------- |
16
+ | `GET` | `/search/text` | required | shared | Text results from SearXNG, reranked |
17
+ | `GET` | `/search/images` | required | shared | Image results from SearXNG, reranked |
18
+ | `GET` | `/page-content` | required | shared | Readable text from result pages, for answer grounding |
19
+ | `GET` | `/thumbnail` | required | own budget | One image-result thumbnail, fetched server-side |
20
+ | `POST` | `/inference` | required | shared | Streaming chat completion from the internal API |
21
+ | `GET` | `/status` | none | none | Uptime, counters, and service health |
22
+ | `GET` | `/api/config` | none | none | Runtime client configuration, including the search token |
23
+ | `POST` | `/api/validate-access-key` | none | shared | Access key check |
24
+ | `GET` | `/dictation-models/<version>/<file>` | none | none | Speech-to-text model files, served from the instance |
25
 
26
  A path that matches no hook falls through to Vite's static handler, which is
27
  also what a method mismatch does on `/api/config` and `/api/validate-access-key`:
 
66
  those two rejections need no token and spend no rate-limit point. The failures
67
  below are the same on every token-gated endpoint:
68
 
69
+ | Status | Body | When |
70
+ | ------ | -------------------------------- | ---------------------------------------------------- |
71
+ | `429` | `{"error":"Too many requests."}` | The bucket for this client is empty |
72
+ | `400` | `{"error":"Missing token."}` | No `token` parameter |
73
+ | `401` | `{"error":"Invalid token."}` | The hash does not verify against this server's token |
74
 
75
  A 401 that persists across reloads usually means two processes hold different
76
  tokens: the server logs that once, on the way out.
 
101
  `server/verifyTokenAndRateLimit.ts` keeps two in-memory buckets, both keyed by
102
  client IP:
103
 
104
+ | Bucket | Budget | Consumed by |
105
+ | --------- | ------------------ | ------------------------------------------------------------------------------------------- |
106
+ | shared | 10 requests / 10 s | `/search/text`, `/search/images`, `/page-content`, `/inference`, `/api/validate-access-key` |
107
+ | thumbnail | 60 requests / 10 s | `/thumbnail` |
108
 
109
  `/thumbnail` has its own budget because one image search fans out into up to 30
110
  tile loads, which would otherwise consume a user's whole search budget. The
 
122
 
123
  Query parameters:
124
 
125
+ | Name | Required | Description |
126
+ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | `q` | yes | Search query, trimmed, 1 to 2000 characters |
128
+ | `token` | yes | Search token hash |
129
+ | `limit` | no | How many of SearXNG's results to consider, default and maximum 30; a value that is not a positive integer falls back to the default. SearXNG is not sent this number: it caps a single SearXNG response after duplicate URLs are dropped, and the score filter below can return fewer |
130
 
131
  Responds with a JSON array of tuples, in the order the client renders them:
132
 
133
  ```json
134
+ [["Result title", "Snippet text", "https://example.com/page", 3.7]]
 
 
135
  ```
136
 
137
  Between SearXNG and the response the server drops duplicate URLs, keeps the
 
165
 
166
  Failures:
167
 
168
+ | Status | Body | When |
169
+ | ------ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
170
+ | `400` | `{"error":"Missing query parameter"}` | `q` missing or blank |
171
+ | `400` | `{"error":"Query parameter must not exceed 2000 characters"}` | `q` too long |
172
+ | `502` | `{"error":"Search service unavailable"}` | SearXNG failed and the text-search fallback was off or failed too, so an outage is distinguishable from a search with no matches |
173
+ | `500` | `{"error":"Internal server error"}` | Anything else |
174
 
175
  With `SEARCH_FALLBACK_ENABLED` on (see `docs/configuration.md`), a text search
176
  whose SearXNG attempt failed is retried against the configured fallback
 
234
 
235
  The path is matched exactly, so `/page-content/anything` falls through.
236
 
237
+ | Name | Required | Description |
238
+ | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
239
+ | `q` | yes | Query the passages are ranked against, 1 to 2000 characters |
240
+ | `token` | yes | Search token hash |
241
+ | `url` | yes | Page to read, `http` or `https`, up to 2048 characters. Repeat for more; at most 6 per request, counted before duplicates are removed, so 7 URLs are refused even when two of them are the same |
242
 
243
  Responds with an object keyed by URL:
244
 
 
264
  Fetches one image-result thumbnail server-side, so the browser never requests a
265
  search-result URL directly.
266
 
267
+ | Name | Required | Description |
268
+ | ------- | -------- | ------------------------------------ |
269
+ | `u` | yes | Thumbnail URL, up to 2048 characters |
270
+ | `token` | yes | Search token hash |
271
 
272
  A hit responds with the image bytes and the upstream's content type, restricted
273
  to `image/avif`, `image/bmp`, `image/gif`, `image/jpeg`, `image/png`,
 
280
  redirects, each of them re-validated, and 500 KB of body. Successful fetches are
281
  held in an in-process LRU (100 entries, 50 MB); failures never are.
282
 
283
+ | Status | Body | When |
284
+ | ------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
285
+ | `400` | `{"error":"Missing thumbnail URL"}` | `u` missing |
286
+ | `400` | `{"error":"Thumbnail URL too long"}` | `u` over 2048 characters |
287
+ | `403` | `{"error":"Refusing to fetch a thumbnail from a non-public or unresolvable address"}` | The host is in private space, does not resolve, or `u` is not a parseable `http`/`https` URL. `/page-content` answers `400` for that last case; this endpoint does not distinguish it |
288
+ | `502` | `{"error":"Thumbnail could not be fetched"}` | Upstream failed, timed out, redirected more than 3 times, answered with a type outside the list, or sent an empty body |
289
+ | `500` | `{"error":"Internal server error"}` | Anything the hook itself threw, caught so Vite's connect stack does not see an unhandled rejection |
290
 
291
  Error responses carry `Cache-Control: no-store`, since neither a refusal nor an
292
  upstream failure is a stable property of the URL.
 
339
 
340
  Failures before the stream starts:
341
 
342
+ | Status | Body | When |
343
+ | ------ | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
344
+ | `405` | `{"error":"Method Not Allowed"}` | Not a `POST`; the response carries `Allow: POST`. Checked before the token, so it needs no token and spends no rate-limit point |
345
+ | `415` | `{"error":"Unsupported Media Type"}` | `Content-Type` is not JSON; also checked before the token |
346
+ | `400` | `{"error":"Invalid request body"}` or `{"error":"Invalid request body: <field> <message>"}` | Unparseable or schema-invalid body |
347
+ | `413` | `{"error":"Request body too large"}` | Body over 1 MiB |
348
+ | `500` | `{"error":"OpenAI API configuration is missing"}` | `INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL` or `_API_KEY` unset |
349
+ | `500` | `{"error":"Failed to fetch available models"}` | No model configured and the provider's listing failed |
350
+ | `500` | `{"error":"No model available"}` | The listing succeeded but was empty |
351
+ | `503` | `{"error":"Service unavailable - all models failed","lastError":"..."}` | Every attempt failed before the first token |
352
+ | `500` | `{"error":"Internal server error","message":"..."}` | Anything else the hook threw |
353
 
354
  ### `GET /status`
355
 
 
423
  the answer is a `413` sent while the caller is still uploading, so it carries
424
  `Connection: close` and the socket is dropped once it has flushed.
425
 
426
+ | Status | Body | When |
427
+ | ------ | ------------------------------------------- | -------------------------------------------------------------------------- |
428
+ | `429` | `{"error":"Too many requests."}` | Rate limited, kept distinct from a wrong key so the UI can say "try again" |
429
+ | `400` | `{"valid":false,"error":"Invalid request"}` | Body is not JSON |
430
+ | `413` | `{"error":"Request body too large"}` | Body over 4 KiB |
431
 
432
  ### `GET /dictation-models/<version>/<file>`
433
 
 
447
  are streamed and carry `Cache-Control: public, max-age=31536000, immutable`,
448
  which the version segment makes safe.
449
 
450
+ | Status | Body | When |
451
+ | ------ | ---------------------------------- | -------------------------------------------------------------------------------------------------------- |
452
+ | `404` | `Unknown dictation model file` | The filename is not one of the seven |
453
+ | `502` | `Could not serve <file>: <reason>` | The upstream failed, sent no body, exceeded the 64 MiB cap, or the bytes did not match the pinned digest |
454
 
455
  A failure once the body has started cannot be reported, because the headers are
456
  already gone; the response is ended rather than having an error appended to a
docs/coding-conventions.md CHANGED
@@ -3,7 +3,6 @@
3
  ## Tech Stack
4
 
5
  React 19 + TypeScript + Mantine UI v9 (`@mantine/core`, `@mantine/hooks`, `@mantine/form`, `@mantine/notifications`, `@mantine/code-highlight`, `@mantine/carousel`)
6
-
7
  - **Build Tool**: Vite 8
8
  - **Linting/Formatting**: Biome 2
9
  - **Testing**: Vitest 5 + Testing Library
 
3
  ## Tech Stack
4
 
5
  React 19 + TypeScript + Mantine UI v9 (`@mantine/core`, `@mantine/hooks`, `@mantine/form`, `@mantine/notifications`, `@mantine/code-highlight`, `@mantine/carousel`)
 
6
  - **Build Tool**: Vite 8
7
  - **Linting/Formatting**: Biome 2
8
  - **Testing**: Vitest 5 + Testing Library
docs/configuration.md CHANGED
@@ -6,10 +6,10 @@ All configuration is done via environment variables. Create a `.env` file in the
6
 
7
  ### Access Control
8
 
9
- | Variable | Default | Description |
10
- |----------|---------|-------------|
11
- | `ACCESS_KEYS` | `''` | Comma-separated list of valid access keys (e.g., `'key1,key2,key3'`) |
12
- | `ACCESS_KEY_TIMEOUT_HOURS` | `24` | Hours to cache validated keys in browser. Set to `0` to require validation on every request |
13
 
14
  **Example:**
15
  ```bash
@@ -21,8 +21,8 @@ ACCESS_KEY_TIMEOUT_HOURS="24"
21
 
22
  Configure default models for different inference types:
23
 
24
- | Variable | Default | Description |
25
- |----------|---------|-------------|
26
  | `WLLAMA_DEFAULT_MODEL_ID` | `littlelamb-290m` | Default Wllama model ID (used for both WebGPU-accelerated and CPU inference) |
27
 
28
  ### Server Model Request Configuration
@@ -32,27 +32,27 @@ optional environment variables. Integer values are parsed as decimal numbers,
32
  and temperature and top-p values are parsed as floating-point numbers. An empty
33
  value uses the corresponding default.
34
 
35
- | Variable | Default | Description |
36
- |----------|---------|-------------|
37
- | `MODEL_MAX_RETRIES` | `5` | Maximum number of retries for a model request |
38
- | `MODEL_BASE_BACKOFF_MS` | `100` | Initial retry backoff in milliseconds |
39
- | `MODEL_MAX_BACKOFF_MS` | `5000` | Maximum retry backoff in milliseconds |
40
- | `MODEL_REQUEST_TIMEOUT_MS` | `30000` | Model request timeout in milliseconds |
41
- | `MODEL_MAX_CONCURRENT_REQUESTS` | `10` | Maximum number of simultaneous model requests |
42
- | `MODEL_DEFAULT_MAX_TOKENS` | `2048` | Default maximum number of generated tokens |
43
- | `MODEL_TEMPERATURE` | `0.7` | Model sampling temperature |
44
- | `MODEL_TOP_P` | `0.9` | Model nucleus-sampling probability |
45
 
46
  ### Internal API Configuration
47
 
48
  For self-hosted OpenAI-compatible APIs:
49
 
50
- | Variable | Default | Description |
51
- |----------|---------|-------------|
52
- | `INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL` | `''` | Base URL of your API (e.g., `https://api.internal.company.com/v1`) |
53
- | `INTERNAL_OPENAI_COMPATIBLE_API_KEY` | `''` | API key for authentication |
54
- | `INTERNAL_OPENAI_COMPATIBLE_API_MODEL` | `''` | Model ID to use (auto-detected if empty) |
55
- | `INTERNAL_OPENAI_COMPATIBLE_API_NAME` | `Internal API` | Display name shown in UI |
56
 
57
  **Example:**
58
  ```bash
@@ -64,46 +64,45 @@ INTERNAL_OPENAI_COMPATIBLE_API_NAME="Company LLM"
64
 
65
  ### Default Behavior
66
 
67
- | Variable | Default | Description |
68
- |----------|---------|-------------|
69
  | `DEFAULT_INFERENCE_TYPE` | `browser` | Default AI inference type (`browser`, `openai`, `horde`, `internal`) |
70
 
71
  ### Server Configuration
72
 
73
  These variables control the Vite development/preview server behavior:
74
 
75
- | Variable | Default | Description |
76
- |----------|---------|-------------|
77
- | `HOST` | `0.0.0.0` | Host address for the Vite server to bind to |
78
- | `PORT` | `7860` | Port number for the main server |
79
- | `HMR_PORT` | `7861` | Port for Hot Module Replacement during development |
80
- | `ALLOWED_HOSTS` | `true` | Comma-separated list of allowed hostnames for the preview server |
81
- | `BASIC_SSL` | `false` | Enable basic SSL for HTTPS support during development |
82
- | `TRUST_PROXY` | `false` | Trust `X-Forwarded-For`/`X-Real-IP` for the per-client rate-limit key. Enable **only** behind a reverse proxy that sets the rightmost `X-Forwarded-For` entry. Leave off when the instance is exposed directly - otherwise clients could forge the header to evade rate limiting. |
83
 
84
  These defaults are provided by `docker-compose.yml`/`docker-compose.production.yml` (e.g. `PORT=${PORT:-7860}`), not by the application itself - `vite.config.ts` reads these variables with no fallback, so when running directly via `npm run dev`/`vite preview` without Docker, unset variables fall through to Vite's own built-in defaults.
85
 
86
  ### Dictation
87
 
88
- | Variable | Default | Description |
89
- |----------|---------|-------------|
90
  | `DICTATION_MODELS_DIR` | `<system temp>/minisearch-dictation-models` | Directory where the server caches the speech-to-text model files it serves under `/dictation-models/`. The files are fetched once from the pinned upstream URL on the first request, verified against a pinned SHA-256, and reused after that. They land in a subdirectory named after the model release, so a later release cannot be served from an older cache. The default lives under the system temp directory, so a container re-downloads ~51 MB after a restart unless this points at a volume |
91
 
92
  ### Search Fallback
93
 
94
  When SearXNG fails on a text search, the server can retry that search against second providers: `search.parallel.ai` (Parallel) and You.com's MCP endpoint (`https://api.you.com/mcp`). Every configured provider takes part. On each search they are put in a random order, and each is tried in turn until one returns results; a provider that answers with nothing usable, or fails, hands the search to the next one. The whole cascade shares one 15-second budget, and each attempt gets the remaining budget divided by the number of providers still untried and not paused, so a provider that hangs cannot use up the time the others need. A single search can therefore reach more than one provider, one after the other. `docker-compose.yml` passes only `HOST`, `PORT`, `BASIC_SSL` and `HMR_PORT` into the development container, so these variables are not carried by the compose `environment` list; set them in `.env`, which `vite.config.ts` loads, inside the container as well as outside it. `vite.config.ts` reads `.env.example` after `.env`, so anything still set there acts as a default: the real API keys belong in `.env`, never in `.env.example`, which is committed.
95
 
96
- | Variable | Default | Description |
97
- |----------|---------|-------------|
98
- | `SEARCH_FALLBACK_ENABLED` | `false` | Serve text results from the fallback providers when SearXNG has failed (an HTTP error, a network error, every engine unresponsive, or an open circuit), under one 15-second budget for the whole cascade. Only `true` or `1` turns it on, and changing it takes a restart, since a process keeps the environment it was started with. Image searches never use it, and a fallback that returns nothing usable is still an HTTP 200 with no results rather than an outage |
99
- | `SEARCH_FALLBACK_PROVIDERS` | `''` (all providers) | Comma-separated list of the providers to try: `parallel`, `youcom`, or both. Names are trimmed and lowercased, and empty items are ignored. Unset or empty means every built-in provider. With `SEARCH_FALLBACK_ENABLED` on, an unknown name, or a list with no names at all (such as `,`), stops the server at start with an error that names the valid values |
100
- | `SEARCH_FALLBACK_PARALLEL_API_KEY` | `''` | Optional API key for Parallel, sent as a Bearer token. Without it the fallback calls Parallel's keyless tier. With `SEARCH_FALLBACK_ENABLED` on, a key with a control character inside it (such as a pasted line break) stops the server at start, since the HTTP client would quote it in its error |
101
- | `SEARCH_FALLBACK_YOUCOM_API_KEY` | `''` | Optional API key for You.com, sent as a Bearer token. Without it the fallback calls the keyless `https://api.you.com/mcp?profile=free` endpoint. A key with a control character inside it stops the server at start, like the Parallel key |
102
 
103
  `SEARCH_FALLBACK_PROVIDER` and `SEARCH_FALLBACK_API_KEY` are gone. If either is still set to a non-empty value while `SEARCH_FALLBACK_ENABLED` is on, the server stops at start with an error that names the variable to use instead, so an upgrade cannot silently drop a key: move the key to `SEARCH_FALLBACK_PARALLEL_API_KEY` or `SEARCH_FALLBACK_YOUCOM_API_KEY`, and the provider choice to `SEARCH_FALLBACK_PROVIDERS`.
104
 
105
  Each provider has its own terms, and each operator is responsible for following them:
106
-
107
  - **Parallel**: Parallel describes its keyless tier as for exploration and light use, and asks for an account and an API key for production use ([docs.parallel.ai](https://docs.parallel.ai/integrations/mcp/search-mcp)).
108
  - **You.com**: the keyless `profile=free` endpoint allows 100 queries per day ([you.com](https://you.com/docs/build-with-agents/mcp-server)). Its API acceptable use policy asks integrations to honor 429 responses ([you.com](https://you.com/acceptable-use-policy-may-2026)).
109
 
@@ -115,41 +114,41 @@ Settings are stored in browser localStorage and can be changed via the Settings
115
 
116
  ### Core Settings
117
 
118
- | Setting | Type | Default | Description |
119
- |---------|------|---------|-------------|
120
- | `enableAiResponse` | boolean | `false` | Enable AI-generated responses for searches |
121
- | `showEnableAiResponsePrompt` | boolean | `true` | Show prompt to enable AI response on first use |
122
- | `enableImageSearch` | boolean | `true` | Include image results in searches |
123
- | `enableTextSearch` | boolean | `true` | Include text results in searches |
124
- | `searchResultsLimit` | number | `15` | Maximum search results to fetch |
125
- | `systemPrompt` | string | (template) | Custom system prompt template for AI |
126
- | `enterToSubmit` | boolean | `true` | Press Enter to submit query (vs Shift+Enter for new line) |
127
- | `enableAiResponseScrolling` | boolean | `true` | Auto-scroll AI response as it generates |
128
- | `enableNotificationOnAiComplete` | boolean | `false` | Show a browser notification when AI response generation finishes |
129
- | `enablePageContentFetch` | boolean | `true` | Read the pages behind the top results and ground the answer on their text instead of on snippets alone. |
130
 
131
  ### Inference Settings
132
 
133
- | Setting | Type | Default | Description |
134
- |---------|------|---------|-------------|
135
- | `inferenceType` | enum | `'browser'` | AI provider: `browser`, `openai`, `horde`, `internal` |
136
- | `cpuThreads` | number | (auto) | Number of CPU threads for inference (Wllama), defaults to half the logical processors (`navigator.hardwareConcurrency / 2`), minimum 1 |
137
- | `allowAiModelDownload` | boolean | `false` | Allow automatic AI model downloads |
138
- | `wllamaModelId` | string | `WLLAMA_DEFAULT_MODEL_ID` env var | Default Wllama model ID |
139
- | `hordeApiKey` | string | `'0000000000'` | AI Horde API key (default is anonymous) |
140
- | `hordeModel` | string | `''` | Specific AI Horde model to request |
141
- | `openAiApiBaseUrl` | string | `''` | Base URL for the OpenAI-compatible API |
142
- | `openAiApiKey` | string | `''` | API key for the OpenAI-compatible API |
143
- | `openAiApiModel` | string | `''` | Model identifier for the OpenAI-compatible API |
144
- | `openAiContextLength` | number | `4096` | Context window size for OpenAI-compatible models, sent as `max_tokens` |
145
 
146
  ### Model Selection
147
 
148
  **Wllama Models:**
149
- - 30+ pre-configured models
150
  - Range from 135M to 4B parameters
151
- - All quantized to Q4_K_S or UD-Q4_K_XL
152
- - Stored at: `Felladrin/gguf-sharded-*` on HuggingFace
153
 
154
  **OpenAI/Internal:**
155
  - Any OpenAI-compatible API
@@ -163,13 +162,13 @@ Settings are stored in browser localStorage and can be changed via the Settings
163
 
164
  ### History Settings
165
 
166
- | Setting | Type | Default | Description |
167
- |---------|------|---------|-------------|
168
- | `enableHistory` | boolean | `true` | Enable search history persistence |
169
- | `historyRetentionDays` | number | `30` | Days to keep search history |
170
- | `historyMaxEntries` | number | `1000` | Maximum history entries before cleanup |
171
- | `historyAutoCleanup` | boolean | `true` | Automatically clean old history entries |
172
- | `historyGroupByDate` | boolean | `true` | Group history entries by date in UI |
173
 
174
  ### System Prompt Configuration
175
 
@@ -204,14 +203,14 @@ Search results:
204
 
205
  ### Privacy Settings
206
 
207
- | Setting | Type | Default | Description |
208
- |---------|------|---------|-------------|
209
- | `selectedVoiceId` | string | `""` | Voice for reading answers aloud. `piper:<voice>` picks a local neural voice, `system:<voiceURI>` an OS voice, and `""` picks one automatically for the browser language. The Voice settings panel lists only the voices of the selected engine, and switching that engine resets this to `""`. A bare value stored by an earlier version is read as an OS voice whenever the system engine runs; with the local engine it is ignored in favor of a language match |
210
- | `textToSpeechEngine` | `"local" \| "system"` | `"local"` | Which engine reads answers aloud, and which set of voices the Voice settings panel lists. `"system"` also overrides a local voice picked above, and stops the voice list from being fetched, so it never contacts a third-party host. A profile that had picked an OS voice before this setting existed starts on `"system"`, so the upgrade does not replace that choice with a model download |
211
- | `enableDictation` | boolean | `true` | Shows the microphone button inside the search field and the chat follow-up field, which fills whichever one it sits in while the user speaks. Turning this off hides the button and loads nothing. The button is also hidden outside a secure context, so an instance served over plain HTTP on a LAN will not show it: no browser grants microphone access there |
212
- | `enableLocalDictationModel` | boolean | `true` only when the browser's primary language is English | Whether the dictation button prefers the on-device Moonshine model, which understands English only. With it off, the button uses the browser's built-in `SpeechRecognition`, which handles other languages but may send the audio to the browser vendor. The preference reorders the two engines and never removes one: on browsers without `SpeechRecognition` the on-device model is used regardless. The default is derived from `navigator.language` at first load, so a non-English browser starts on the built-in recognizer with no model download; the Voice settings panel can flip it either way |
213
- | `reasoningStartMarker` | string | `"<think>"` | Marker for start of reasoning content |
214
- | `reasoningEndMarker` | string | `"</think>"` | Marker for end of reasoning content |
215
 
216
  ## Docker Configuration
217
 
@@ -232,7 +231,7 @@ services:
232
  dockerfile: Dockerfile
233
  context: .
234
  volumes:
235
- - .:/home/node/app/ # Live code mounting
236
  command:
237
  [
238
  "(cd /usr/local/searxng/searxng-src && /usr/local/searxng/searxng-venv/bin/python -m searx.webapp > /tmp/searxng.log 2>&1) & (npm install && npm run dev)",
@@ -246,8 +245,8 @@ Same structure but without volume mounts and with pre-built assets.
246
  ### Dockerfile Environment
247
 
248
  The Dockerfile sets up a single runtime stage:
249
- - Node.js LTS
250
- - Python 3 + SearXNG
251
 
252
  The app runs under the `node` user, with the app directory at `/home/node/app`. The production image starts the app with `npm start -- --host` (i.e. `vite preview`), not `npm run dev`.
253
 
@@ -343,7 +342,6 @@ services:
343
  ```
344
 
345
  Two things about that command are required, not optional:
346
-
347
  - **The file must be readable by uid 1000.** The container runs as `node`, and a file readable only by its owner on the host is invisible to it.
348
  - **`secret_key` must be set to your own random value.** SearXNG refuses to start on the `ultrasecretkey` placeholder and logs `server.secret_key is not changed. Please use something else instead of ultrasecretkey.` The image's own file never carries the placeholder, because the Dockerfile replaces it at build time, but a file you write has to carry a real key.
349
 
@@ -391,16 +389,16 @@ Client-facing configuration (access keys, inference type, internal API settings)
391
 
392
  ### Build-Time vs Runtime Configuration
393
 
394
- | Value | Resolved At | Notes |
395
- |-------|-------------|-------|
396
- | `VITE_BUILD_DATE_TIME` | Build time | Epoch milliseconds when the build occurred |
397
- | `VITE_COMMIT_SHORT_HASH` | Build time | Git commit hash at build time (if available) |
398
- | `ACCESS_KEYS` | Runtime | Read from `/api/config` |
399
- | `ACCESS_KEY_TIMEOUT_HOURS` | Runtime | Read from `/api/config` |
400
- | `WLLAMA_DEFAULT_MODEL_ID` | Runtime | Read from `/api/config` |
401
- | `INTERNAL_OPENAI_COMPATIBLE_API_*` | Runtime | Read from `/api/config` (except `API_KEY` which is server-only) |
402
- | `DEFAULT_INFERENCE_TYPE` | Runtime | Read from `/api/config` |
403
- | Search token | Runtime | Read from `/api/config`, so a reload picks up the token of whichever server answers |
404
 
405
  ### Security Considerations
406
 
@@ -447,8 +445,8 @@ MiniSearch logs internal events to an in-app log panel (see the Logs section of
447
  Check effective configuration:
448
  ```typescript
449
  // In browser console
450
- console.log('Settings:', JSON.parse(localStorage.getItem('settings') || '{}'));
451
- console.log('Server config:', await fetch('/api/config').then(r => r.json()));
452
  ```
453
 
454
  ## Related Topics
 
6
 
7
  ### Access Control
8
 
9
+ | Variable | Default | Description |
10
+ | -------------------------- | ------- | ------------------------------------------------------------------------------------------- |
11
+ | `ACCESS_KEYS` | `''` | Comma-separated list of valid access keys (e.g., `'key1,key2,key3'`) |
12
+ | `ACCESS_KEY_TIMEOUT_HOURS` | `24` | Hours to cache validated keys in browser. Set to `0` to require validation on every request |
13
 
14
  **Example:**
15
  ```bash
 
21
 
22
  Configure default models for different inference types:
23
 
24
+ | Variable | Default | Description |
25
+ | ------------------------- | ----------------- | ---------------------------------------------------------------------------- |
26
  | `WLLAMA_DEFAULT_MODEL_ID` | `littlelamb-290m` | Default Wllama model ID (used for both WebGPU-accelerated and CPU inference) |
27
 
28
  ### Server Model Request Configuration
 
32
  and temperature and top-p values are parsed as floating-point numbers. An empty
33
  value uses the corresponding default.
34
 
35
+ | Variable | Default | Description |
36
+ | ------------------------------- | ------- | --------------------------------------------- |
37
+ | `MODEL_MAX_RETRIES` | `5` | Maximum number of retries for a model request |
38
+ | `MODEL_BASE_BACKOFF_MS` | `100` | Initial retry backoff in milliseconds |
39
+ | `MODEL_MAX_BACKOFF_MS` | `5000` | Maximum retry backoff in milliseconds |
40
+ | `MODEL_REQUEST_TIMEOUT_MS` | `30000` | Model request timeout in milliseconds |
41
+ | `MODEL_MAX_CONCURRENT_REQUESTS` | `10` | Maximum number of simultaneous model requests |
42
+ | `MODEL_DEFAULT_MAX_TOKENS` | `2048` | Default maximum number of generated tokens |
43
+ | `MODEL_TEMPERATURE` | `0.7` | Model sampling temperature |
44
+ | `MODEL_TOP_P` | `0.9` | Model nucleus-sampling probability |
45
 
46
  ### Internal API Configuration
47
 
48
  For self-hosted OpenAI-compatible APIs:
49
 
50
+ | Variable | Default | Description |
51
+ | ----------------------------------------- | -------------- | ------------------------------------------------------------------ |
52
+ | `INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL` | `''` | Base URL of your API (e.g., `https://api.internal.company.com/v1`) |
53
+ | `INTERNAL_OPENAI_COMPATIBLE_API_KEY` | `''` | API key for authentication |
54
+ | `INTERNAL_OPENAI_COMPATIBLE_API_MODEL` | `''` | Model ID to use (auto-detected if empty) |
55
+ | `INTERNAL_OPENAI_COMPATIBLE_API_NAME` | `Internal API` | Display name shown in UI |
56
 
57
  **Example:**
58
  ```bash
 
64
 
65
  ### Default Behavior
66
 
67
+ | Variable | Default | Description |
68
+ | ------------------------ | --------- | -------------------------------------------------------------------- |
69
  | `DEFAULT_INFERENCE_TYPE` | `browser` | Default AI inference type (`browser`, `openai`, `horde`, `internal`) |
70
 
71
  ### Server Configuration
72
 
73
  These variables control the Vite development/preview server behavior:
74
 
75
+ | Variable | Default | Description |
76
+ | --------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
77
+ | `HOST` | `0.0.0.0` | Host address for the Vite server to bind to |
78
+ | `PORT` | `7860` | Port number for the main server |
79
+ | `HMR_PORT` | `7861` | Port for Hot Module Replacement during development |
80
+ | `ALLOWED_HOSTS` | `true` | Comma-separated list of allowed hostnames for the preview server |
81
+ | `BASIC_SSL` | `false` | Enable basic SSL for HTTPS support during development |
82
+ | `TRUST_PROXY` | `false` | Trust `X-Forwarded-For`/`X-Real-IP` for the per-client rate-limit key. Enable **only** behind a reverse proxy that sets the rightmost `X-Forwarded-For` entry. Leave off when the instance is exposed directly - otherwise clients could forge the header to evade rate limiting. |
83
 
84
  These defaults are provided by `docker-compose.yml`/`docker-compose.production.yml` (e.g. `PORT=${PORT:-7860}`), not by the application itself - `vite.config.ts` reads these variables with no fallback, so when running directly via `npm run dev`/`vite preview` without Docker, unset variables fall through to Vite's own built-in defaults.
85
 
86
  ### Dictation
87
 
88
+ | Variable | Default | Description |
89
+ | ---------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
90
  | `DICTATION_MODELS_DIR` | `<system temp>/minisearch-dictation-models` | Directory where the server caches the speech-to-text model files it serves under `/dictation-models/`. The files are fetched once from the pinned upstream URL on the first request, verified against a pinned SHA-256, and reused after that. They land in a subdirectory named after the model release, so a later release cannot be served from an older cache. The default lives under the system temp directory, so a container re-downloads ~51 MB after a restart unless this points at a volume |
91
 
92
  ### Search Fallback
93
 
94
  When SearXNG fails on a text search, the server can retry that search against second providers: `search.parallel.ai` (Parallel) and You.com's MCP endpoint (`https://api.you.com/mcp`). Every configured provider takes part. On each search they are put in a random order, and each is tried in turn until one returns results; a provider that answers with nothing usable, or fails, hands the search to the next one. The whole cascade shares one 15-second budget, and each attempt gets the remaining budget divided by the number of providers still untried and not paused, so a provider that hangs cannot use up the time the others need. A single search can therefore reach more than one provider, one after the other. `docker-compose.yml` passes only `HOST`, `PORT`, `BASIC_SSL` and `HMR_PORT` into the development container, so these variables are not carried by the compose `environment` list; set them in `.env`, which `vite.config.ts` loads, inside the container as well as outside it. `vite.config.ts` reads `.env.example` after `.env`, so anything still set there acts as a default: the real API keys belong in `.env`, never in `.env.example`, which is committed.
95
 
96
+ | Variable | Default | Description |
97
+ | ---------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
98
+ | `SEARCH_FALLBACK_ENABLED` | `false` | Serve text results from the fallback providers when SearXNG has failed (an HTTP error, a network error, every engine unresponsive, or an open circuit), under one 15-second budget for the whole cascade. Only `true` or `1` turns it on, and changing it takes a restart, since a process keeps the environment it was started with. Image searches never use it, and a fallback that returns nothing usable is still an HTTP 200 with no results rather than an outage |
99
+ | `SEARCH_FALLBACK_PROVIDERS` | `''` (all providers) | Comma-separated list of the providers to try: `parallel`, `youcom`, or both. Names are trimmed and lowercased, and empty items are ignored. Unset or empty means every built-in provider. With `SEARCH_FALLBACK_ENABLED` on, an unknown name, or a list with no names at all (such as `,`), stops the server at start with an error that names the valid values |
100
+ | `SEARCH_FALLBACK_PARALLEL_API_KEY` | `''` | Optional API key for Parallel, sent as a Bearer token. Without it the fallback calls Parallel's keyless tier. With `SEARCH_FALLBACK_ENABLED` on, a key with a control character inside it (such as a pasted line break) stops the server at start, since the HTTP client would quote it in its error |
101
+ | `SEARCH_FALLBACK_YOUCOM_API_KEY` | `''` | Optional API key for You.com, sent as a Bearer token. Without it the fallback calls the keyless `https://api.you.com/mcp?profile=free` endpoint. A key with a control character inside it stops the server at start, like the Parallel key |
102
 
103
  `SEARCH_FALLBACK_PROVIDER` and `SEARCH_FALLBACK_API_KEY` are gone. If either is still set to a non-empty value while `SEARCH_FALLBACK_ENABLED` is on, the server stops at start with an error that names the variable to use instead, so an upgrade cannot silently drop a key: move the key to `SEARCH_FALLBACK_PARALLEL_API_KEY` or `SEARCH_FALLBACK_YOUCOM_API_KEY`, and the provider choice to `SEARCH_FALLBACK_PROVIDERS`.
104
 
105
  Each provider has its own terms, and each operator is responsible for following them:
 
106
  - **Parallel**: Parallel describes its keyless tier as for exploration and light use, and asks for an account and an API key for production use ([docs.parallel.ai](https://docs.parallel.ai/integrations/mcp/search-mcp)).
107
  - **You.com**: the keyless `profile=free` endpoint allows 100 queries per day ([you.com](https://you.com/docs/build-with-agents/mcp-server)). Its API acceptable use policy asks integrations to honor 429 responses ([you.com](https://you.com/acceptable-use-policy-may-2026)).
108
 
 
114
 
115
  ### Core Settings
116
 
117
+ | Setting | Type | Default | Description |
118
+ | -------------------------------- | ------- | ---------- | ------------------------------------------------------------------------------------------------------- |
119
+ | `enableAiResponse` | boolean | `false` | Enable AI-generated responses for searches |
120
+ | `showEnableAiResponsePrompt` | boolean | `true` | Show prompt to enable AI response on first use |
121
+ | `enableImageSearch` | boolean | `true` | Include image results in searches |
122
+ | `enableTextSearch` | boolean | `true` | Include text results in searches |
123
+ | `searchResultsLimit` | number | `15` | Maximum search results to fetch |
124
+ | `systemPrompt` | string | (template) | Custom system prompt template for AI |
125
+ | `enterToSubmit` | boolean | `true` | Press Enter to submit query (vs Shift+Enter for new line) |
126
+ | `enableAiResponseScrolling` | boolean | `true` | Auto-scroll AI response as it generates |
127
+ | `enableNotificationOnAiComplete` | boolean | `false` | Show a browser notification when AI response generation finishes |
128
+ | `enablePageContentFetch` | boolean | `true` | Read the pages behind the top results and ground the answer on their text instead of on snippets alone. |
129
 
130
  ### Inference Settings
131
 
132
+ | Setting | Type | Default | Description |
133
+ | ---------------------- | ------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
134
+ | `inferenceType` | enum | `'browser'` | AI provider: `browser`, `openai`, `horde`, `internal` |
135
+ | `cpuThreads` | number | (auto) | Number of CPU threads for inference (Wllama), defaults to half the logical processors (`navigator.hardwareConcurrency / 2`), minimum 1 |
136
+ | `allowAiModelDownload` | boolean | `false` | Allow automatic AI model downloads |
137
+ | `wllamaModelId` | string | `WLLAMA_DEFAULT_MODEL_ID` env var | Default Wllama model ID |
138
+ | `hordeApiKey` | string | `'0000000000'` | AI Horde API key (default is anonymous) |
139
+ | `hordeModel` | string | `''` | Specific AI Horde model to request |
140
+ | `openAiApiBaseUrl` | string | `''` | Base URL for the OpenAI-compatible API |
141
+ | `openAiApiKey` | string | `''` | API key for the OpenAI-compatible API |
142
+ | `openAiApiModel` | string | `''` | Model identifier for the OpenAI-compatible API |
143
+ | `openAiContextLength` | number | `4096` | Context window size for OpenAI-compatible models, sent as `max_tokens` |
144
 
145
  ### Model Selection
146
 
147
  **Wllama Models:**
148
+ - 39 pre-configured models
149
  - Range from 135M to 4B parameters
150
+ - All quantized to a Q3/Q4-class quant (Q4_K_S, Q4_K_M, Q3_K_S, UD-Q3_K_XL or UD-Q4_K_XL)
151
+ - Stored at: `Felladrin/gguf-sharded-*` on HuggingFace, always as sharded GGUFs
152
 
153
  **OpenAI/Internal:**
154
  - Any OpenAI-compatible API
 
162
 
163
  ### History Settings
164
 
165
+ | Setting | Type | Default | Description |
166
+ | ---------------------- | ------- | ------- | --------------------------------------- |
167
+ | `enableHistory` | boolean | `true` | Enable search history persistence |
168
+ | `historyRetentionDays` | number | `30` | Days to keep search history |
169
+ | `historyMaxEntries` | number | `1000` | Maximum history entries before cleanup |
170
+ | `historyAutoCleanup` | boolean | `true` | Automatically clean old history entries |
171
+ | `historyGroupByDate` | boolean | `true` | Group history entries by date in UI |
172
 
173
  ### System Prompt Configuration
174
 
 
203
 
204
  ### Privacy Settings
205
 
206
+ | Setting | Type | Default | Description |
207
+ | --------------------------- | --------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
208
+ | `selectedVoiceId` | string | `""` | Voice for reading answers aloud. `piper:<voice>` picks a local neural voice, `system:<voiceURI>` an OS voice, and `""` picks one automatically for the browser language. The Voice settings panel lists only the voices of the selected engine, and switching that engine resets this to `""`. A bare value stored by an earlier version is read as an OS voice whenever the system engine runs; with the local engine it is ignored in favor of a language match |
209
+ | `textToSpeechEngine` | `"local" \| "system"` | `"local"` | Which engine reads answers aloud, and which set of voices the Voice settings panel lists. `"system"` also overrides a local voice picked above, and stops the voice list from being fetched, so it never contacts a third-party host. A profile that had picked an OS voice before this setting existed starts on `"system"`, so the upgrade does not replace that choice with a model download |
210
+ | `enableDictation` | boolean | `true` | Shows the microphone button inside the search field and the chat follow-up field, which fills whichever one it sits in while the user speaks. Turning this off hides the button and loads nothing. The button is also hidden outside a secure context, so an instance served over plain HTTP on a LAN will not show it: no browser grants microphone access there |
211
+ | `enableLocalDictationModel` | boolean | `true` only when the browser's primary language is English | Whether the dictation button prefers the on-device Moonshine model, which understands English only. With it off, the button uses the browser's built-in `SpeechRecognition`, which handles other languages but may send the audio to the browser vendor. The preference reorders the two engines and never removes one: on browsers without `SpeechRecognition` the on-device model is used regardless. The default is derived from `navigator.language` at first load, so a non-English browser starts on the built-in recognizer with no model download; the Voice settings panel can flip it either way |
212
+ | `reasoningStartMarker` | string | `"<think>"` | Marker for start of reasoning content |
213
+ | `reasoningEndMarker` | string | `"</think>"` | Marker for end of reasoning content |
214
 
215
  ## Docker Configuration
216
 
 
231
  dockerfile: Dockerfile
232
  context: .
233
  volumes:
234
+ - .:/home/node/app/ # Live code mounting
235
  command:
236
  [
237
  "(cd /usr/local/searxng/searxng-src && /usr/local/searxng/searxng-venv/bin/python -m searx.webapp > /tmp/searxng.log 2>&1) & (npm install && npm run dev)",
 
245
  ### Dockerfile Environment
246
 
247
  The Dockerfile sets up a single runtime stage:
248
+ - Node.js LTS
249
+ - Python 3 + SearXNG
250
 
251
  The app runs under the `node` user, with the app directory at `/home/node/app`. The production image starts the app with `npm start -- --host` (i.e. `vite preview`), not `npm run dev`.
252
 
 
342
  ```
343
 
344
  Two things about that command are required, not optional:
 
345
  - **The file must be readable by uid 1000.** The container runs as `node`, and a file readable only by its owner on the host is invisible to it.
346
  - **`secret_key` must be set to your own random value.** SearXNG refuses to start on the `ultrasecretkey` placeholder and logs `server.secret_key is not changed. Please use something else instead of ultrasecretkey.` The image's own file never carries the placeholder, because the Dockerfile replaces it at build time, but a file you write has to carry a real key.
347
 
 
389
 
390
  ### Build-Time vs Runtime Configuration
391
 
392
+ | Value | Resolved At | Notes |
393
+ | ---------------------------------- | ----------- | ----------------------------------------------------------------------------------- |
394
+ | `VITE_BUILD_DATE_TIME` | Build time | Epoch milliseconds when the build occurred |
395
+ | `VITE_COMMIT_SHORT_HASH` | Build time | Git commit hash at build time (if available) |
396
+ | `ACCESS_KEYS` | Runtime | Read from `/api/config` |
397
+ | `ACCESS_KEY_TIMEOUT_HOURS` | Runtime | Read from `/api/config` |
398
+ | `WLLAMA_DEFAULT_MODEL_ID` | Runtime | Read from `/api/config` |
399
+ | `INTERNAL_OPENAI_COMPATIBLE_API_*` | Runtime | Read from `/api/config` (except `API_KEY` which is server-only) |
400
+ | `DEFAULT_INFERENCE_TYPE` | Runtime | Read from `/api/config` |
401
+ | Search token | Runtime | Read from `/api/config`, so a reload picks up the token of whichever server answers |
402
 
403
  ### Security Considerations
404
 
 
445
  Check effective configuration:
446
  ```typescript
447
  // In browser console
448
+ console.log("Settings:", JSON.parse(localStorage.getItem("settings") || "{}"));
449
+ console.log("Server config:", await fetch("/api/config").then((r) => r.json()));
450
  ```
451
 
452
  ## Related Topics
docs/conversation-memory.md CHANGED
@@ -1,15 +1,18 @@
1
  # Conversation Memory System
2
 
3
  ## Purpose
 
4
  Long-running chats can easily exceed the model context window. MiniSearch addresses this by keeping a rolling, extractive summary of prior turns and only feeding the freshest messages into the model alongside that summary. All context handling happens locally in the browser to preserve privacy.
5
 
6
  ## Components
 
7
  1. **Token Budgeting** โ€“ `generateChatResponse` measures the system prompt and a stub "Ok!" assistant reply, then caps the rest of the user/assistant turns at 75% of the default 4096-token window (โ‰ˆ3072 tokens) to leave headroom for the response. A GPT tokenizer keeps count per message before inclusion.
8
  2. **Rolling Summary Storage** โ€“ The latest summary plus a conversation identifier live in a lightweight pub/sub store (`client/modules/pubSub.ts`) so any component can read/write without prop drilling.
9
  3. **Summarization Engine** โ€“ When older turns must be dropped, `createLlmSummary` asks the configured inference backend (OpenAI, AI Horde, internal API, or Wllama) to condense the removed messages under an 800-token limit. If the LLM call fails, the system falls back to an extractive tokenizer-based summarizer to guarantee progress.
10
  4. **Persistence Hooks** โ€“ After a search run completes, `saveLlmResponseForQuery` stores the assistant reply in IndexedDB so history restores can reload it. The conversation summary itself stays in-memory and resets whenever a new search run begins.
11
 
12
  ## Flow
 
13
  1. User sends a chat message.
14
  2. System prompt is regenerated by `getSystemPrompt` and augmented with any stored summary (`Conversation context: ...`).
15
  3. Recent turns are appended until the budget is exhausted; older ones become "dropped messages".
@@ -17,15 +20,18 @@ Long-running chats can easily exceed the model context window. MiniSearch addres
17
  5. The final prompt sent to the model always starts with the refreshed system prompt, followed by the stub assistant reply and the kept turn list to encourage immediate streaming.
18
 
19
  ## Settings & Extensibility
 
20
  - All inference types share the same summarization contractโ€”no provider-specific logic beyond selecting the backend module at runtime.
21
  - The 75%-of-4096 conversation-memory budget in `generateChatResponse` is hardcoded against `defaultContextSize` (4096) and does not currently scale with the user-configurable `openAiContextLength` setting; that setting only caps the `max_tokens` of the outgoing API request, a separate concern from how many prior chat turns are kept in memory.
22
  - Future settings (e.g., toggling memory or adjusting the 75% ratio) should hook into the same budgeting helpers to keep behavior predictable.
23
 
24
  ## Failure Modes & Logging
 
25
  - Every summarization attempt is wrapped in try/catch; failures emit `addLogEntry` notifications and fall back to extractive summaries so the chat loop never stalls.
26
  - If generation is interrupted (user stop), a custom `ChatGenerationError` ensures the loop exits gracefully without corrupting the stored summary.
27
 
28
  ## Reset Rules
 
29
  - Starting a new top-level search clears the summary, chat history, and cached results to avoid context leakage across unrelated conversations.
30
  - Restoring a run from history repopulates chat state from IndexedDB; the memory system will rebuild summaries on demand once the user resumes chatting.
31
 
 
1
  # Conversation Memory System
2
 
3
  ## Purpose
4
+
5
  Long-running chats can easily exceed the model context window. MiniSearch addresses this by keeping a rolling, extractive summary of prior turns and only feeding the freshest messages into the model alongside that summary. All context handling happens locally in the browser to preserve privacy.
6
 
7
  ## Components
8
+
9
  1. **Token Budgeting** โ€“ `generateChatResponse` measures the system prompt and a stub "Ok!" assistant reply, then caps the rest of the user/assistant turns at 75% of the default 4096-token window (โ‰ˆ3072 tokens) to leave headroom for the response. A GPT tokenizer keeps count per message before inclusion.
10
  2. **Rolling Summary Storage** โ€“ The latest summary plus a conversation identifier live in a lightweight pub/sub store (`client/modules/pubSub.ts`) so any component can read/write without prop drilling.
11
  3. **Summarization Engine** โ€“ When older turns must be dropped, `createLlmSummary` asks the configured inference backend (OpenAI, AI Horde, internal API, or Wllama) to condense the removed messages under an 800-token limit. If the LLM call fails, the system falls back to an extractive tokenizer-based summarizer to guarantee progress.
12
  4. **Persistence Hooks** โ€“ After a search run completes, `saveLlmResponseForQuery` stores the assistant reply in IndexedDB so history restores can reload it. The conversation summary itself stays in-memory and resets whenever a new search run begins.
13
 
14
  ## Flow
15
+
16
  1. User sends a chat message.
17
  2. System prompt is regenerated by `getSystemPrompt` and augmented with any stored summary (`Conversation context: ...`).
18
  3. Recent turns are appended until the budget is exhausted; older ones become "dropped messages".
 
20
  5. The final prompt sent to the model always starts with the refreshed system prompt, followed by the stub assistant reply and the kept turn list to encourage immediate streaming.
21
 
22
  ## Settings & Extensibility
23
+
24
  - All inference types share the same summarization contractโ€”no provider-specific logic beyond selecting the backend module at runtime.
25
  - The 75%-of-4096 conversation-memory budget in `generateChatResponse` is hardcoded against `defaultContextSize` (4096) and does not currently scale with the user-configurable `openAiContextLength` setting; that setting only caps the `max_tokens` of the outgoing API request, a separate concern from how many prior chat turns are kept in memory.
26
  - Future settings (e.g., toggling memory or adjusting the 75% ratio) should hook into the same budgeting helpers to keep behavior predictable.
27
 
28
  ## Failure Modes & Logging
29
+
30
  - Every summarization attempt is wrapped in try/catch; failures emit `addLogEntry` notifications and fall back to extractive summaries so the chat loop never stalls.
31
  - If generation is interrupted (user stop), a custom `ChatGenerationError` ensures the loop exits gracefully without corrupting the stored summary.
32
 
33
  ## Reset Rules
34
+
35
  - Starting a new top-level search clears the summary, chat history, and cached results to avoid context leakage across unrelated conversations.
36
  - Restoring a run from history repopulates chat state from IndexedDB; the memory system will rebuild summaries on demand once the user resumes chatting.
37
 
docs/design.md CHANGED
@@ -11,6 +11,7 @@
11
  ## Design Principles
12
 
13
  ### User Interface
 
14
  - Minimal visual clutter with Mantine v9 components
15
  - Clear hierarchy and information architecture
16
  - Keyboard-first navigation with comprehensive shortcuts
@@ -19,6 +20,7 @@
19
  - Dark mode by default (via Mantine's `MantineProvider`)
20
 
21
  ### Performance
 
22
  - Lazy loading for routes and components with React.lazy()
23
  - Optimized bundle size using Vite 8 build optimizations
24
  - Efficient state management with create-pubsub
@@ -27,6 +29,7 @@
27
  - Bundle analysis with rollup-plugin-visualizer
28
 
29
  ### Accessibility
 
30
  - WCAG-aligned where implemented
31
  - Screen reader support
32
  - Keyboard navigation
@@ -47,12 +50,14 @@
47
  ## Technology Integration
48
 
49
  ### AI & Search
 
50
  - Multiple inference engines: Wllama (WebGPU or CPU), OpenAI-compatible APIs
51
  - Privacy-first local processing when possible
52
  - SearXNG integration for web search
53
  - Graceful fallbacks for unsupported browsers
54
 
55
  ### Development Experience
 
56
  - Hot Module Replacement with Vite
57
  - Biome for consistent formatting and linting
58
  - Comprehensive testing with Vitest and Testing Library
 
11
  ## Design Principles
12
 
13
  ### User Interface
14
+
15
  - Minimal visual clutter with Mantine v9 components
16
  - Clear hierarchy and information architecture
17
  - Keyboard-first navigation with comprehensive shortcuts
 
20
  - Dark mode by default (via Mantine's `MantineProvider`)
21
 
22
  ### Performance
23
+
24
  - Lazy loading for routes and components with React.lazy()
25
  - Optimized bundle size using Vite 8 build optimizations
26
  - Efficient state management with create-pubsub
 
29
  - Bundle analysis with rollup-plugin-visualizer
30
 
31
  ### Accessibility
32
+
33
  - WCAG-aligned where implemented
34
  - Screen reader support
35
  - Keyboard navigation
 
50
  ## Technology Integration
51
 
52
  ### AI & Search
53
+
54
  - Multiple inference engines: Wllama (WebGPU or CPU), OpenAI-compatible APIs
55
  - Privacy-first local processing when possible
56
  - SearXNG integration for web search
57
  - Graceful fallbacks for unsupported browsers
58
 
59
  ### Development Experience
60
+
61
  - Hot Module Replacement with Vite
62
  - Biome for consistent formatting and linting
63
  - Comprehensive testing with Vitest and Testing Library
docs/development-commands.md CHANGED
@@ -26,7 +26,6 @@ Failure-injection cases (dependency down, empty results, aborted stream) are cat
26
  `eval/` is an offline eval that gives a regression signal for changes to the
27
  reranker, the system prompt, the search-results formatting, or the model. See
28
  `eval/README.md` for details and the LLM-judge environment variables.
29
-
30
  - **`npm run eval`**: Run the full eval (retrieval + answer)
31
  - **`npm run eval:retrieval`**: Retrieval eval (real ONNX reranker; local only, needs the model in `server/models/`)
32
  - **`npm run eval:answer`**: Answer eval (LLM judge; gated on `EVAL_LLM_API_KEY`, skips without one)
@@ -34,8 +33,8 @@ reranker, the system prompt, the search-results formatting, or the model. See
34
  ### Coverage Reports for AI Analysis
35
 
36
  After running `test:coverage`, AI agents can analyze these JSON files:
37
-
38
  - **`coverage/coverage-summary.json`**: Quick metrics view - overall percentages per file
 
39
  ```json
40
  {
41
  "total": {
@@ -64,22 +63,21 @@ The repository uses nine GitHub Actions workflows for continuous integration, de
64
 
65
  ### Workflow Files
66
 
67
- | Workflow | Trigger | Purpose |
68
- |---|---|---|
69
- | `ci.yml` | Push/PR to `main` or `master` | Full lint (`npm run lint`), format check (`npm run format`), and Vitest test suite |
70
- | `security.yml` | Push/PR to `main` or `master` | Secret scanning (Gitleaks) and license compliance check (`npm run license-check`) with artifact upload |
71
- | `on-push-to-main.yml` | Push to `main` | Delegates to `reusable-check-docker.yml` |
72
- | `on-pull-request-to-main.yml` | PR opened/synced/reopened to `main` | Delegates to `reusable-check-docker.yml`; skippable via `skip-check-docker` label |
73
- | `publish-docker-image.yml` | Manual (`workflow_dispatch`), `main` only | Cuts a CalVer release: validates the optional `version` input (empty auto-computes today's date in UTC plus the next free same-day sequence), builds the multi-platform Docker image (linux/amd64, linux/arm64) and pushes it to `ghcr.io` as that version plus `latest` (build job, `contents: read` + `packages: write`), then a second job holding `contents: write` pushes the git tag and publishes the GitHub Release. The release job is idempotent: when a run pushed the tag but failed before publishing the Release, re-running that job alone finishes it without rebuilding |
74
- | `scan-docker-image.yml` | Weekly (Monday 06:00 UTC) or manual | Trivy scan of the published image, reporting fixable HIGH/CRITICAL findings to code scanning |
75
- | `deploy-to-hugging-face.yml` | Manual (`workflow_dispatch`) | Syncs the repository to a Hugging Face Space with the `hf` CLI over OIDC Trusted Publishers |
76
- | `stale.yml` | Daily (01:30 UTC) or manual | Marks and closes inactive issues and labels stale pull requests |
77
- | `reusable-check-docker.yml` | Called by other workflows | Docker compose production build + health check via `curl localhost:7860` (lint/format/test are covered by `ci.yml`) |
78
 
79
  ### Reusable Workflow (`reusable-check-docker.yml`)
80
 
81
  Used by both `on-push-to-main` and `on-pull-request-to-main` to run the production container smoke test:
82
-
83
  1. **check-docker-container** - Builds and starts the production container with `docker compose -f docker-compose.production.yml up -d`, then verifies the main page returns HTTP 200 within 60 seconds
84
 
85
  ### Docker Image Builds
 
26
  `eval/` is an offline eval that gives a regression signal for changes to the
27
  reranker, the system prompt, the search-results formatting, or the model. See
28
  `eval/README.md` for details and the LLM-judge environment variables.
 
29
  - **`npm run eval`**: Run the full eval (retrieval + answer)
30
  - **`npm run eval:retrieval`**: Retrieval eval (real ONNX reranker; local only, needs the model in `server/models/`)
31
  - **`npm run eval:answer`**: Answer eval (LLM judge; gated on `EVAL_LLM_API_KEY`, skips without one)
 
33
  ### Coverage Reports for AI Analysis
34
 
35
  After running `test:coverage`, AI agents can analyze these JSON files:
 
36
  - **`coverage/coverage-summary.json`**: Quick metrics view - overall percentages per file
37
+
38
  ```json
39
  {
40
  "total": {
 
63
 
64
  ### Workflow Files
65
 
66
+ | Workflow | Trigger | Purpose |
67
+ | ----------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
68
+ | `ci.yml` | Push/PR to `main` or `master` | Full lint (`npm run lint`), format check (`npm run format`), and Vitest test suite |
69
+ | `security.yml` | Push/PR to `main` or `master` | Secret scanning (Gitleaks) and license compliance check (`npm run license-check`) with artifact upload |
70
+ | `on-push-to-main.yml` | Push to `main` | Delegates to `reusable-check-docker.yml` |
71
+ | `on-pull-request-to-main.yml` | PR opened/synced/reopened to `main` | Delegates to `reusable-check-docker.yml`; skippable via `skip-check-docker` label |
72
+ | `publish-docker-image.yml` | Manual (`workflow_dispatch`), `main` only | Cuts a CalVer release: validates the optional `version` input (empty auto-computes today's date in UTC plus the next free same-day sequence), builds the multi-platform Docker image (linux/amd64, linux/arm64) and pushes it to `ghcr.io` as that version plus `latest` (build job, `contents: read` + `packages: write`), then a second job holding `contents: write` pushes the git tag and publishes the GitHub Release. The release job is idempotent: when a run pushed the tag but failed before publishing the Release, re-running that job alone finishes it without rebuilding |
73
+ | `scan-docker-image.yml` | Weekly (Monday 06:00 UTC) or manual | Trivy scan of the published image, reporting fixable HIGH/CRITICAL findings to code scanning |
74
+ | `deploy-to-hugging-face.yml` | Manual (`workflow_dispatch`) | Syncs the repository to a Hugging Face Space with the `hf` CLI over OIDC Trusted Publishers |
75
+ | `stale.yml` | Daily (01:30 UTC) or manual | Marks and closes inactive issues and labels stale pull requests |
76
+ | `reusable-check-docker.yml` | Called by other workflows | Docker compose production build + health check via `curl localhost:7860` (lint/format/test are covered by `ci.yml`) |
77
 
78
  ### Reusable Workflow (`reusable-check-docker.yml`)
79
 
80
  Used by both `on-push-to-main` and `on-pull-request-to-main` to run the production container smoke test:
 
81
  1. **check-docker-container** - Builds and starts the production container with `docker compose -f docker-compose.production.yml up -d`, then verifies the main page returns HTTP 200 within 60 seconds
82
 
83
  ### Docker Image Builds
docs/failure-injection.md CHANGED
@@ -8,94 +8,94 @@ needed.
8
 
9
  ## Degradation Matrix
10
 
11
- | Failure injected | Expected degradation | Where it is pinned |
12
- | --- | --- | --- |
13
- | SearXNG answers 500 once, then recovers | Retried with exponential backoff, results returned | `server/webSearchService.test.ts` โ€บ retry logic |
14
- | SearXNG answers 500 on every attempt | Retry cycle exhausts (4 requests) and costs a single circuit-breaker failure | `server/webSearchService.test.ts` โ€บ graceful degradation |
15
- | SearXNG answers 200 with no results and a transiently unresponsive engine (e.g. a timeout) once, then recovers | Retried with the same backoff, results returned, not counted as a failure | `server/webSearchService.test.ts` โ€บ retry logic |
16
- | SearXNG answers 200 with no results and a non-suspending engine error (e.g. server API error) on every attempt | Retry cycle exhausts (4 requests), counts one failure, and `fetchSearXNG` throws with the engine reasons rather than an empty set, so the case takes the provider-down path below | `server/webSearchService.test.ts` โ€บ retry logic, graceful degradation |
17
- | SearXNG answers 200 with no results and every unresponsive engine under a long suspension (CAPTCHA, rate limit) | Fails fast on the first attempt (1 request, no backoff), counts one failure, and throws the same error: a suspension outlasts the whole backoff by hours to a day | `server/webSearchService.test.ts` โ€บ retry logic |
18
- | SearXNG fails five cycles in a row | Circuit opens; further searches short-circuit without calling the upstream | `server/webSearchService.test.ts` โ€บ graceful degradation |
19
- | SearXNG recovers after the reset timeout | One healthy response closes the circuit again | `server/webSearchService.test.ts` โ€บ graceful degradation |
20
- | SearXNG's circuit is open while `/healthz` still answers OK | `webSearchServiceStatus` reports `unhealthy`: every search is failing, so the liveness probe alone must not report health | `server/webSearchService.test.ts` โ€บ getWebSearchServiceStatus |
21
- | A search is lost to unresponsive engines, then SearXNG answers the next one of that type | `webSearchServiceStatus` reports `degraded`, then `healthy` again; the per-engine failure counts stay | `server/webSearchService.test.ts` โ€บ getWebSearchServiceStatus |
22
- | A text search is lost to unresponsive engines and the image search the client fires next answers | Still `degraded`, and `searches.degradedSearchTypes` names the pool: the two go out to different engine pools, so answering one says nothing about the other | `server/webSearchService.test.ts` โ€บ getWebSearchServiceStatus |
23
- | The circuit reaches its reset timeout with no search since | `degraded`, not `healthy`: half-open is a timer, not a proven recovery | `server/webSearchService.test.ts` โ€บ getWebSearchServiceStatus |
24
- | SearXNG answers 200 with a non-JSON body | `fetchSearXNG` throws and the endpoint answers HTTP 502 | `server/webSearchService.test.ts` โ€บ graceful degradation |
25
- | SearXNG returns results that are all unusable | Empty result set, no throw | `server/webSearchService.test.ts` โ€บ graceful degradation |
26
- | Provider down vs. genuinely zero results | Provider down: `fetchSearXNG` throws, the endpoint answers HTTP 502, and the client search state becomes `failed`; genuinely zero results: HTTP 200 with `[]`, state stays `completed` and the no-results alert renders | `server/webSearchService.test.ts` and `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
27
- | SearXNG is down | `/search/text` and `/search/images` answer HTTP 502 with a JSON error | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
28
- | Reranker is not ready | Results served in SearXNG order, HTTP 200 | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
29
- | Reranking throws mid-request | Results served in SearXNG order, HTTP 200 | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
30
- | Reranker is not ready on an image search | Images still served, with the thumbnail URLs as SearXNG sent them | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
31
- | Reranking throws on an image search | Images still served, with the thumbnail URLs as SearXNG sent them | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
32
- | Reranker returns a URL absent from the result set | That image is dropped | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
33
- | SearXNG is down and the text-search fallback answers | The fallback's results are ranked and served as HTTP 200 on the normal text path, `searchesServedByFallback` is incremented, and no search duration is recorded | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation โ€บ text search fallback |
34
- | SearXNG is down and the fallback answers with nothing usable | HTTP 200 with `[]`, so the client shows the no-results alert instead of the unavailable one, and `searchesServedByFallback` is still incremented | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation โ€บ text search fallback |
35
- | SearXNG is down and the fallback fails too | HTTP 502 with a JSON error, `searchesFailedOnFallback` is incremented, and the log line carries the fallback's status code but never the query | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation โ€บ text search fallback |
36
- | SearXNG fails with too little of the search deadline left | The fallback is never consulted, `searchesFailedOnFallback` is incremented, and `/search/text` answers HTTP 502 rather than a result the client would never receive | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation โ€บ text search fallback |
37
- | SearXNG is down while the fallback is switched off | HTTP 502; the fallback is never consulted and neither fallback counter moves | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation โ€บ text search fallback |
38
- | SearXNG is down on an image search while the fallback is on | HTTP 502; the fallback is text-only, so it is never consulted and neither fallback counter moves | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation โ€บ text search fallback |
39
- | SearXNG answers while the fallback is on | HTTP 200 from SearXNG's results; the fallback is never consulted and neither fallback counter moves | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation โ€บ text search fallback |
40
- | The first fallback provider fails and the next one answers | The cascade moves on to the next provider within what is left of the shared budget, and that provider's results are served; the failure is logged with the provider name and a status code, never the query | `server/fallbackSearchService.test.ts` โ€บ the cascade |
41
- | A fallback provider answers HTTP 429 | That provider is paused until its `Retry-After` (60 s when missing or invalid, at most an hour), the next provider serves the search, and later searches skip the paused one without a request until the pause ends | `server/fallbackSearchService.test.ts` โ€บ pausing a rate-limited provider |
42
- | Every fallback provider is paused | No provider is called, the cascade throws, and `/search/text` answers HTTP 502 as for a fallback that failed | `server/fallbackSearchService.test.ts` โ€บ pausing a rate-limited provider |
43
- | Thumbnail host never answers | Request aborted after the timeout, `/thumbnail` answers HTTP 502 and the tile shows the host name | `server/thumbnailEndpointServerHook.test.ts` |
44
- | DNS lookup for a thumbnail never settles | Bounded by the same deadline as the fetch, `/thumbnail` answers HTTP 502 | `server/thumbnailEndpointServerHook.test.ts` |
45
- | Thumbnail URL resolves into a private range | Refused before any request, `/thumbnail` answers HTTP 403 | `server/thumbnailEndpointServerHook.test.ts` |
46
- | Thumbnail redirects into a private range | Redirect not followed, HTTP 403 | `server/thumbnailEndpointServerHook.test.ts` |
47
- | Thumbnail redirect carries a malformed Location | Not followed, HTTP 502, no unhandled rejection | `server/thumbnailEndpointServerHook.test.ts` |
48
- | Thumbnail upstream answers with a non-raster or an empty body (SVG included) | `/thumbnail` answers HTTP 502, the tile shows the host name | `server/thumbnailEndpointServerHook.test.ts` |
49
- | Thumbnail body exceeds the byte cap | Body truncated at the cap and served | `server/thumbnailEndpointServerHook.test.ts` |
50
- | Thumbnail upstream refuses once, then recovers | The failure is not cached; the next tile load fetches again and serves | `server/thumbnailEndpointServerHook.test.ts` |
51
- | `/thumbnail` budget is spent | HTTP 429 for the tiles only; the search budget is a separate limiter | `server/handleTokenVerification.test.ts`, `server/thumbnailEndpointServerHook.test.ts` |
52
- | A tile's `/thumbnail` request fails in the browser | That tile shows the host name, the rest of the grid is untouched | `client/components/Search/Results/Graphical/ImageResultsList.test.tsx` |
53
- | SearXNG returns an image result with no thumbnail URL | The result is dropped, so the grid never shows a host-name tile for it | `server/webSearchService.test.ts` โ€บ graceful degradation |
54
- | Search returns nothing to the endpoint | HTTP 200 with `[]`, not an error | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
55
- | Text search returns nothing to the client | Keyword-only query retried as a fallback | `client/modules/textGeneration.degradation.test.ts` |
56
- | Keyword fallback also returns nothing | Text search state stays `completed` with the no-results alert | `client/modules/textGeneration.degradation.test.ts` |
57
- | SearXNG is down on the client | Text search state becomes `failed`, keyword fallback does not fire | `client/modules/textGeneration.degradation.test.ts` |
58
- | Client search fails after a previous search populated the LLM channel | The grounding channel is cleared, so the AI can't ground on the previous query's results; when image search is on and it answers, the channel is repopulated with the (image)-tagged titles and URLs instead | `client/modules/textGeneration.degradation.test.ts` |
59
- | Text search fails and the image search answers | The answer waits for the image results and grounds on their (image)-tagged titles and URLs, and the failed-state alert says so | `client/modules/textGeneration.degradation.test.ts` โ€บ search degradation |
60
- | Live search fails but a stale cached result exists for the same query | The cached result is served flagged stale and the UI shows the "Showing cached results" banner | `client/modules/search.test.ts` โ€บ Stale Result Fallback |
61
- | Result page host resolves into a private range | Page skipped, no request is made | `server/pageContentService.test.ts` โ€บ fetchPageContents |
62
- | Result page redirects into a private range | Redirect not followed, page skipped | `server/pageContentService.test.ts` โ€บ fetchPageContents |
63
- | Result page errors, is not a document, or yields no text | That page is skipped, the others still return | `server/pageContentService.test.ts` โ€บ fetchPageContents |
64
- | Result host refuses three reads in a row | The host is skipped without a request for the cooling window, and the skip is counted as `skippedByBreaker` | `server/pageContentService.test.ts` โ€บ host circuit breaker |
65
- | The window passes on a skipped host | One read probes the host; a success reopens it, a refusal boxes it again | `server/pageContentService.test.ts` โ€บ host circuit breaker |
66
- | A skipped host appears twice in one batch after the window | One probe is sent, the other read is skipped | `server/pageContentService.test.ts` โ€บ host circuit breaker |
67
- | Result page body never ends | Reading stops at the byte cap | `server/pageContentService.test.ts` โ€บ fetchPageContents |
68
- | `/page-content` errors or never answers | Answer falls back to snippets, search still completes | `client/modules/pageContent.test.ts`, `client/modules/textGeneration.degradation.test.ts` โ€บ page content grounding |
69
- | `/inference` upstream is not configured | HTTP 500 with a JSON error | `server/internalApiEndpointServerHook.test.ts` โ€บ environment configuration |
70
- | `/inference` cannot list upstream models | HTTP 500 with a JSON error | `server/internalApiEndpointServerHook.test.ts` โ€บ streaming path |
71
- | Upstream model fails but another one is available | Retried on the next model, answer still streams | `server/internalApiEndpointServerHook.test.ts` โ€บ streaming path |
72
- | Every model fails before a single token is sent | HTTP 503 with a JSON error | `server/internalApiEndpointServerHook.test.ts` โ€บ streaming path |
73
- | Upstream stream ends without a finish part | Treated as a failure: retried, then `Stream ended unexpectedly` as the last error | `server/internalApiEndpointServerHook.test.ts` โ€บ streaming path |
74
- | Upstream fails after content was already sent | No retry, SSE error frame, the partial answer stands | `server/internalApiEndpointServerHook.test.ts` โ€บ streaming path |
75
- | Response is already unwritable when streaming would start | No headers set, the upstream is never called | `server/internalApiEndpointServerHook.test.ts` โ€บ streaming path |
76
- | `/inference` answers 503 | Generation state becomes `failed`, nothing persisted | `client/modules/textGeneration.degradation.test.ts` |
77
- | Generation interrupted mid-stream | Partial answer preserved, state stays `interrupted` | `client/modules/textGeneration.degradation.test.ts` |
78
- | The local voice catalogue cannot be fetched | The local engine is skipped and the answer is read by an OS voice | `client/modules/textToSpeech.test.ts` โ€บ falls back when the voice catalogue cannot be reached |
79
- | No local voice matches the language | Same, with a log entry naming the reason | `client/modules/textToSpeech.test.ts` โ€บ falls back when no local voice matches the language |
80
- | The synthesis worker fails before anything is audible | The answer is read by an OS voice instead | `client/modules/textToSpeech.test.ts` โ€บ falls back to the system voice when the local engine cannot load, falls back when the worker fails and no chunk was ever audible |
81
- | Every synthesized chunk fails to play (blocked autoplay) | Treated as a local-engine failure, so an OS voice reads the answer rather than the user getting silence | `client/modules/textToSpeech.test.ts` โ€บ falls back when every synthesized chunk fails to play |
82
- | The browser provides no `speechSynthesis` at all and the local engine failed | Playback resolves and returns to idle with a log entry, instead of an unhandled rejection | `client/modules/textToSpeech.test.ts` โ€บ resolves and logs when the local engine failed and there are no OS voices |
83
- | A caller streams an oversized body to `/api/validate-access-key` | Refused with `413` mid-upload and the socket dropped; the answer carries `Connection: close`, so the caller's next request opens a fresh connection instead of a dead one | `server/validateAccessKeyServerHook.socket.test.ts` โ€บ refuses an oversized body with 413 without breaking the next request |
84
- | Two follow-up question generations overlap | The older completion writes nothing, so the flag stays on until the newest finishes and the input keeps the newest question | `client/components/AiResponse/ChatInterface.test.tsx` โ€บ keeps the follow-up question flag on until the newest call finishes |
85
- | `ChatInterface` unmounts while a follow-up question is in flight | Both generation flags are cleared and the orphaned completion writes nothing into the next mount | `client/components/AiResponse/ChatInterface.test.tsx` โ€บ leaves both generation flags off after unmounting mid-generation |
86
- | A follow-up question generation rejects after a newer one started | The failed call blanks nothing, so the newer question still lands and the flag stays on until it does | `client/components/AiResponse/ChatInterface.test.tsx` โ€บ blanks nothing when an orphaned call rejects after a newer one started |
87
- | `ChatInterface` unmounts while a regenerate is still awaiting its response | The orphaned regenerate leaves the next mount's response flag alone | `client/components/AiResponse/ChatInterface.test.tsx` โ€บ leaves the next mount's response flag alone when an orphaned regenerate settles |
88
- | `ChatInterface` unmounts while a send is still awaiting its response | The orphaned send asks for no follow-up question and leaves the next mount's response flag alone | `client/components/AiResponse/ChatInterface.test.tsx` โ€บ starts no follow-up question for a send that settles after unmount, leaves the next mount's response flag alone when an orphaned send settles |
89
- | The dictation model cannot load in the browser | While the on-device model is preferred, the local engine is abandoned for the browser's `SpeechRecognition`, and the button hides when that is missing too | `client/modules/speechToText.test.ts` โ€บ falls back to the browser recognizer when the local engine fails |
90
- | The browser recognizer fails while the on-device model is off | The session fails outright; there is no reverse fallback, so the disabled model is never downloaded behind the user's back | `client/modules/speechToText.test.ts` โ€บ does not fall back to the wasm engine when the recognizer fails |
91
- | Microphone permission is denied | A notification says so instead of the press failing quietly, and no fallback asks again | `client/components/DictationButton.test.tsx` โ€บ shows a notification when microphone permission is denied, `client/modules/speechToText.test.ts` โ€บ reports a denied microphone instead of falling back |
92
- | The dictation engine fails while the microphone permission prompt is open | The load fails rather than resolving into a dead transcriber, so the browser recognizer takes over | `client/modules/speechToText.test.ts` โ€บ fails the load when the engine dies while permission is pending, fails the load when the worker dies uncaught while permission is pending |
93
- | The browser recognizer is denied after the session started | Reported through the error callback, since the promise it would have rejected has already resolved | `client/modules/speechToText.test.ts` โ€บ reports a denial that arrives after the session resolved |
94
- | The browser recognizer ends on its own after silence | The session ends and the button leaves its recording state, with no notification, because nothing failed | `client/modules/speechToText.test.ts` โ€บ ends the session quietly when the recognizer stops on its own, `client/components/DictationButton.test.tsx` โ€บ leaves the recording state quietly when the engine ends on its own |
95
- | The dictation engine fails after it loaded | A notification says so and the session stops, instead of the UI listening forever with the microphone open | `client/modules/speechToText.test.ts` โ€บ reports a worker failure that happens after the engine loaded, `client/components/DictationButton.test.tsx` โ€บ notifies and stops when the engine fails after it loaded |
96
- | The search form unmounts while dictation is recording | The microphone track, the AudioContext and the worker are all released | `client/components/DictationButton.test.tsx` โ€บ releases the microphone when the button unmounts mid-recording |
97
- | A chat response starts generating while dictation is recording in the follow-up box | The session is stopped instead of splicing its transcript into a field that has gone read-only and is about to be cleared | `client/components/AiResponse/ChatInputArea.test.tsx` โ€บ stops a running session when a response starts generating |
98
- | An upstream dictation model file is unavailable | `/dictation-models/` answers a 502 rather than caching a partial file | `server/dictationModelServerHook.test.ts` โ€บ returns a 502 when the upstream download fails |
99
 
100
  ## Adding a Row
101
 
 
8
 
9
  ## Degradation Matrix
10
 
11
+ | Failure injected | Expected degradation | Where it is pinned |
12
+ | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
13
+ | SearXNG answers 500 once, then recovers | Retried with exponential backoff, results returned | `server/webSearchService.test.ts` โ€บ retry logic |
14
+ | SearXNG answers 500 on every attempt | Retry cycle exhausts (4 requests) and costs a single circuit-breaker failure | `server/webSearchService.test.ts` โ€บ graceful degradation |
15
+ | SearXNG answers 200 with no results and a transiently unresponsive engine (e.g. a timeout) once, then recovers | Retried with the same backoff, results returned, not counted as a failure | `server/webSearchService.test.ts` โ€บ retry logic |
16
+ | SearXNG answers 200 with no results and a non-suspending engine error (e.g. server API error) on every attempt | Retry cycle exhausts (4 requests), counts one failure, and `fetchSearXNG` throws with the engine reasons rather than an empty set, so the case takes the provider-down path below | `server/webSearchService.test.ts` โ€บ retry logic, graceful degradation |
17
+ | SearXNG answers 200 with no results and every unresponsive engine under a long suspension (CAPTCHA, rate limit) | Fails fast on the first attempt (1 request, no backoff), counts one failure, and throws the same error: a suspension outlasts the whole backoff by hours to a day | `server/webSearchService.test.ts` โ€บ retry logic |
18
+ | SearXNG fails five cycles in a row | Circuit opens; further searches short-circuit without calling the upstream | `server/webSearchService.test.ts` โ€บ graceful degradation |
19
+ | SearXNG recovers after the reset timeout | One healthy response closes the circuit again | `server/webSearchService.test.ts` โ€บ graceful degradation |
20
+ | SearXNG's circuit is open while `/healthz` still answers OK | `webSearchServiceStatus` reports `unhealthy`: every search is failing, so the liveness probe alone must not report health | `server/webSearchService.test.ts` โ€บ getWebSearchServiceStatus |
21
+ | A search is lost to unresponsive engines, then SearXNG answers the next one of that type | `webSearchServiceStatus` reports `degraded`, then `healthy` again; the per-engine failure counts stay | `server/webSearchService.test.ts` โ€บ getWebSearchServiceStatus |
22
+ | A text search is lost to unresponsive engines and the image search the client fires next answers | Still `degraded`, and `searches.degradedSearchTypes` names the pool: the two go out to different engine pools, so answering one says nothing about the other | `server/webSearchService.test.ts` โ€บ getWebSearchServiceStatus |
23
+ | The circuit reaches its reset timeout with no search since | `degraded`, not `healthy`: half-open is a timer, not a proven recovery | `server/webSearchService.test.ts` โ€บ getWebSearchServiceStatus |
24
+ | SearXNG answers 200 with a non-JSON body | `fetchSearXNG` throws and the endpoint answers HTTP 502 | `server/webSearchService.test.ts` โ€บ graceful degradation |
25
+ | SearXNG returns results that are all unusable | Empty result set, no throw | `server/webSearchService.test.ts` โ€บ graceful degradation |
26
+ | Provider down vs. genuinely zero results | Provider down: `fetchSearXNG` throws, the endpoint answers HTTP 502, and the client search state becomes `failed`; genuinely zero results: HTTP 200 with `[]`, state stays `completed` and the no-results alert renders | `server/webSearchService.test.ts` and `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
27
+ | SearXNG is down | `/search/text` and `/search/images` answer HTTP 502 with a JSON error | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
28
+ | Reranker is not ready | Results served in SearXNG order, HTTP 200 | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
29
+ | Reranking throws mid-request | Results served in SearXNG order, HTTP 200 | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
30
+ | Reranker is not ready on an image search | Images still served, with the thumbnail URLs as SearXNG sent them | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
31
+ | Reranking throws on an image search | Images still served, with the thumbnail URLs as SearXNG sent them | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
32
+ | Reranker returns a URL absent from the result set | That image is dropped | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
33
+ | SearXNG is down and the text-search fallback answers | The fallback's results are ranked and served as HTTP 200 on the normal text path, `searchesServedByFallback` is incremented, and no search duration is recorded | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation โ€บ text search fallback |
34
+ | SearXNG is down and the fallback answers with nothing usable | HTTP 200 with `[]`, so the client shows the no-results alert instead of the unavailable one, and `searchesServedByFallback` is still incremented | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation โ€บ text search fallback |
35
+ | SearXNG is down and the fallback fails too | HTTP 502 with a JSON error, `searchesFailedOnFallback` is incremented, and the log line carries the fallback's status code but never the query | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation โ€บ text search fallback |
36
+ | SearXNG fails with too little of the search deadline left | The fallback is never consulted, `searchesFailedOnFallback` is incremented, and `/search/text` answers HTTP 502 rather than a result the client would never receive | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation โ€บ text search fallback |
37
+ | SearXNG is down while the fallback is switched off | HTTP 502; the fallback is never consulted and neither fallback counter moves | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation โ€บ text search fallback |
38
+ | SearXNG is down on an image search while the fallback is on | HTTP 502; the fallback is text-only, so it is never consulted and neither fallback counter moves | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation โ€บ text search fallback |
39
+ | SearXNG answers while the fallback is on | HTTP 200 from SearXNG's results; the fallback is never consulted and neither fallback counter moves | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation โ€บ text search fallback |
40
+ | The first fallback provider fails and the next one answers | The cascade moves on to the next provider within what is left of the shared budget, and that provider's results are served; the failure is logged with the provider name and a status code, never the query | `server/fallbackSearchService.test.ts` โ€บ the cascade |
41
+ | A fallback provider answers HTTP 429 | That provider is paused until its `Retry-After` (60 s when missing or invalid, at most an hour), the next provider serves the search, and later searches skip the paused one without a request until the pause ends | `server/fallbackSearchService.test.ts` โ€บ pausing a rate-limited provider |
42
+ | Every fallback provider is paused | No provider is called, the cascade throws, and `/search/text` answers HTTP 502 as for a fallback that failed | `server/fallbackSearchService.test.ts` โ€บ pausing a rate-limited provider |
43
+ | Thumbnail host never answers | Request aborted after the timeout, `/thumbnail` answers HTTP 502 and the tile shows the host name | `server/thumbnailEndpointServerHook.test.ts` |
44
+ | DNS lookup for a thumbnail never settles | Bounded by the same deadline as the fetch, `/thumbnail` answers HTTP 502 | `server/thumbnailEndpointServerHook.test.ts` |
45
+ | Thumbnail URL resolves into a private range | Refused before any request, `/thumbnail` answers HTTP 403 | `server/thumbnailEndpointServerHook.test.ts` |
46
+ | Thumbnail redirects into a private range | Redirect not followed, HTTP 403 | `server/thumbnailEndpointServerHook.test.ts` |
47
+ | Thumbnail redirect carries a malformed Location | Not followed, HTTP 502, no unhandled rejection | `server/thumbnailEndpointServerHook.test.ts` |
48
+ | Thumbnail upstream answers with a non-raster or an empty body (SVG included) | `/thumbnail` answers HTTP 502, the tile shows the host name | `server/thumbnailEndpointServerHook.test.ts` |
49
+ | Thumbnail body exceeds the byte cap | Body truncated at the cap and served | `server/thumbnailEndpointServerHook.test.ts` |
50
+ | Thumbnail upstream refuses once, then recovers | The failure is not cached; the next tile load fetches again and serves | `server/thumbnailEndpointServerHook.test.ts` |
51
+ | `/thumbnail` budget is spent | HTTP 429 for the tiles only; the search budget is a separate limiter | `server/handleTokenVerification.test.ts`, `server/thumbnailEndpointServerHook.test.ts` |
52
+ | A tile's `/thumbnail` request fails in the browser | That tile shows the host name, the rest of the grid is untouched | `client/components/Search/Results/Graphical/ImageResultsList.test.tsx` |
53
+ | SearXNG returns an image result with no thumbnail URL | The result is dropped, so the grid never shows a host-name tile for it | `server/webSearchService.test.ts` โ€บ graceful degradation |
54
+ | Search returns nothing to the endpoint | HTTP 200 with `[]`, not an error | `server/searchEndpointServerHook.test.ts` โ€บ graceful degradation |
55
+ | Text search returns nothing to the client | Keyword-only query retried as a fallback | `client/modules/textGeneration.degradation.test.ts` |
56
+ | Keyword fallback also returns nothing | Text search state stays `completed` with the no-results alert | `client/modules/textGeneration.degradation.test.ts` |
57
+ | SearXNG is down on the client | Text search state becomes `failed`, keyword fallback does not fire | `client/modules/textGeneration.degradation.test.ts` |
58
+ | Client search fails after a previous search populated the LLM channel | The grounding channel is cleared, so the AI can't ground on the previous query's results; when image search is on and it answers, the channel is repopulated with the (image)-tagged titles and URLs instead | `client/modules/textGeneration.degradation.test.ts` |
59
+ | Text search fails and the image search answers | The answer waits for the image results and grounds on their (image)-tagged titles and URLs, and the failed-state alert says so | `client/modules/textGeneration.degradation.test.ts` โ€บ search degradation |
60
+ | Live search fails but a stale cached result exists for the same query | The cached result is served flagged stale and the UI shows the "Showing cached results" banner | `client/modules/search.test.ts` โ€บ Stale Result Fallback |
61
+ | Result page host resolves into a private range | Page skipped, no request is made | `server/pageContentService.test.ts` โ€บ fetchPageContents |
62
+ | Result page redirects into a private range | Redirect not followed, page skipped | `server/pageContentService.test.ts` โ€บ fetchPageContents |
63
+ | Result page errors, is not a document, or yields no text | That page is skipped, the others still return | `server/pageContentService.test.ts` โ€บ fetchPageContents |
64
+ | Result host refuses three reads in a row | The host is skipped without a request for the cooling window, and the skip is counted as `skippedByBreaker` | `server/pageContentService.test.ts` โ€บ host circuit breaker |
65
+ | The window passes on a skipped host | One read probes the host; a success reopens it, a refusal boxes it again | `server/pageContentService.test.ts` โ€บ host circuit breaker |
66
+ | A skipped host appears twice in one batch after the window | One probe is sent, the other read is skipped | `server/pageContentService.test.ts` โ€บ host circuit breaker |
67
+ | Result page body never ends | Reading stops at the byte cap | `server/pageContentService.test.ts` โ€บ fetchPageContents |
68
+ | `/page-content` errors or never answers | Answer falls back to snippets, search still completes | `client/modules/pageContent.test.ts`, `client/modules/textGeneration.degradation.test.ts` โ€บ page content grounding |
69
+ | `/inference` upstream is not configured | HTTP 500 with a JSON error | `server/internalApiEndpointServerHook.test.ts` โ€บ environment configuration |
70
+ | `/inference` cannot list upstream models | HTTP 500 with a JSON error | `server/internalApiEndpointServerHook.test.ts` โ€บ streaming path |
71
+ | Upstream model fails but another one is available | Retried on the next model, answer still streams | `server/internalApiEndpointServerHook.test.ts` โ€บ streaming path |
72
+ | Every model fails before a single token is sent | HTTP 503 with a JSON error | `server/internalApiEndpointServerHook.test.ts` โ€บ streaming path |
73
+ | Upstream stream ends without a finish part | Treated as a failure: retried, then `Stream ended unexpectedly` as the last error | `server/internalApiEndpointServerHook.test.ts` โ€บ streaming path |
74
+ | Upstream fails after content was already sent | No retry, SSE error frame, the partial answer stands | `server/internalApiEndpointServerHook.test.ts` โ€บ streaming path |
75
+ | Response is already unwritable when streaming would start | No headers set, the upstream is never called | `server/internalApiEndpointServerHook.test.ts` โ€บ streaming path |
76
+ | `/inference` answers 503 | Generation state becomes `failed`, nothing persisted | `client/modules/textGeneration.degradation.test.ts` |
77
+ | Generation interrupted mid-stream | Partial answer preserved, state stays `interrupted` | `client/modules/textGeneration.degradation.test.ts` |
78
+ | The local voice catalogue cannot be fetched | The local engine is skipped and the answer is read by an OS voice | `client/modules/textToSpeech.test.ts` โ€บ falls back when the voice catalogue cannot be reached |
79
+ | No local voice matches the language | Same, with a log entry naming the reason | `client/modules/textToSpeech.test.ts` โ€บ falls back when no local voice matches the language |
80
+ | The synthesis worker fails before anything is audible | The answer is read by an OS voice instead | `client/modules/textToSpeech.test.ts` โ€บ falls back to the system voice when the local engine cannot load, falls back when the worker fails and no chunk was ever audible |
81
+ | Every synthesized chunk fails to play (blocked autoplay) | Treated as a local-engine failure, so an OS voice reads the answer rather than the user getting silence | `client/modules/textToSpeech.test.ts` โ€บ falls back when every synthesized chunk fails to play |
82
+ | The browser provides no `speechSynthesis` at all and the local engine failed | Playback resolves and returns to idle with a log entry, instead of an unhandled rejection | `client/modules/textToSpeech.test.ts` โ€บ resolves and logs when the local engine failed and there are no OS voices |
83
+ | A caller streams an oversized body to `/api/validate-access-key` | Refused with `413` mid-upload and the socket dropped; the answer carries `Connection: close`, so the caller's next request opens a fresh connection instead of a dead one | `server/validateAccessKeyServerHook.socket.test.ts` โ€บ refuses an oversized body with 413 without breaking the next request |
84
+ | Two follow-up question generations overlap | The older completion writes nothing, so the flag stays on until the newest finishes and the input keeps the newest question | `client/components/AiResponse/ChatInterface.test.tsx` โ€บ keeps the follow-up question flag on until the newest call finishes |
85
+ | `ChatInterface` unmounts while a follow-up question is in flight | Both generation flags are cleared and the orphaned completion writes nothing into the next mount | `client/components/AiResponse/ChatInterface.test.tsx` โ€บ leaves both generation flags off after unmounting mid-generation |
86
+ | A follow-up question generation rejects after a newer one started | The failed call blanks nothing, so the newer question still lands and the flag stays on until it does | `client/components/AiResponse/ChatInterface.test.tsx` โ€บ blanks nothing when an orphaned call rejects after a newer one started |
87
+ | `ChatInterface` unmounts while a regenerate is still awaiting its response | The orphaned regenerate leaves the next mount's response flag alone | `client/components/AiResponse/ChatInterface.test.tsx` โ€บ leaves the next mount's response flag alone when an orphaned regenerate settles |
88
+ | `ChatInterface` unmounts while a send is still awaiting its response | The orphaned send asks for no follow-up question and leaves the next mount's response flag alone | `client/components/AiResponse/ChatInterface.test.tsx` โ€บ starts no follow-up question for a send that settles after unmount, leaves the next mount's response flag alone when an orphaned send settles |
89
+ | The dictation model cannot load in the browser | While the on-device model is preferred, the local engine is abandoned for the browser's `SpeechRecognition`, and the button hides when that is missing too | `client/modules/speechToText.test.ts` โ€บ falls back to the browser recognizer when the local engine fails |
90
+ | The browser recognizer fails while the on-device model is off | The session fails outright; there is no reverse fallback, so the disabled model is never downloaded behind the user's back | `client/modules/speechToText.test.ts` โ€บ does not fall back to the wasm engine when the recognizer fails |
91
+ | Microphone permission is denied | A notification says so instead of the press failing quietly, and no fallback asks again | `client/components/DictationButton.test.tsx` โ€บ shows a notification when microphone permission is denied, `client/modules/speechToText.test.ts` โ€บ reports a denied microphone instead of falling back |
92
+ | The dictation engine fails while the microphone permission prompt is open | The load fails rather than resolving into a dead transcriber, so the browser recognizer takes over | `client/modules/speechToText.test.ts` โ€บ fails the load when the engine dies while permission is pending, fails the load when the worker dies uncaught while permission is pending |
93
+ | The browser recognizer is denied after the session started | Reported through the error callback, since the promise it would have rejected has already resolved | `client/modules/speechToText.test.ts` โ€บ reports a denial that arrives after the session resolved |
94
+ | The browser recognizer ends on its own after silence | The session ends and the button leaves its recording state, with no notification, because nothing failed | `client/modules/speechToText.test.ts` โ€บ ends the session quietly when the recognizer stops on its own, `client/components/DictationButton.test.tsx` โ€บ leaves the recording state quietly when the engine ends on its own |
95
+ | The dictation engine fails after it loaded | A notification says so and the session stops, instead of the UI listening forever with the microphone open | `client/modules/speechToText.test.ts` โ€บ reports a worker failure that happens after the engine loaded, `client/components/DictationButton.test.tsx` โ€บ notifies and stops when the engine fails after it loaded |
96
+ | The search form unmounts while dictation is recording | The microphone track, the AudioContext and the worker are all released | `client/components/DictationButton.test.tsx` โ€บ releases the microphone when the button unmounts mid-recording |
97
+ | A chat response starts generating while dictation is recording in the follow-up box | The session is stopped instead of splicing its transcript into a field that has gone read-only and is about to be cleared | `client/components/AiResponse/ChatInputArea.test.tsx` โ€บ stops a running session when a response starts generating |
98
+ | An upstream dictation model file is unavailable | `/dictation-models/` answers a 502 rather than caching a partial file | `server/dictationModelServerHook.test.ts` โ€บ returns a 502 when the upstream download fails |
99
 
100
  ## Adding a Row
101
 
docs/glossary.md CHANGED
@@ -7,7 +7,6 @@ Codebase-specific terms, jargon, and domain concepts used in MiniSearch.
7
  ### Search Token & Hash
8
 
9
  A security mechanism used to authorize communication between the client and the internal search/AI endpoints.
10
-
11
  - **Search Token**: A random string held by the running server, served to the client at runtime through `/api/config`. Used to verify that requests to the server come from a client that server handed the token to.
12
  - **Search Token Hash**: To avoid exposing the raw token in all requests, the client generates a hash of the token. Managed via the `lastSearchTokenHashPubSub` channel.
13
  - **Verification**: The server verifies these tokens to prevent unauthorized access to the search API. Stored in `server/verifiedTokens.ts` as an in-memory `Set<string>`.
@@ -16,17 +15,16 @@ A security mechanism used to authorize communication between the client and the
16
 
17
  MiniSearch supports multiple backends for Large Language Model (LLM) inference, configured via `inferenceType` in the application settings.
18
 
19
- | Type | Description | Implementation |
20
- |------|-------------|----------------|
21
- | `browser` | Local inference using WASM (Wllama) | Client-side, privacy-preserving |
22
- | `openai` | Connection to any OpenAI-compatible external API | Requires API key |
23
- | `horde` | Crowdsourced inference via the AI Horde network | Distributed, anonymous or authenticated |
24
- | `internal` | Server-side proxy using pre-configured credentials | API key hidden from client |
25
 
26
  ### PubSub (State Management)
27
 
28
  Instead of a heavy state management library like Redux, MiniSearch uses a minimalist Publish-Subscribe pattern powered by the `create-pubsub` library.
29
-
30
  - **Data Flow**: Components subscribe to "channels" (e.g., `queryPubSub`, `responsePubSub`)
31
  - **Tuple Pattern**: Each channel is a 3-element tuple: `[update, subscribe, get]`
32
  - **Persistence**: Some channels use `createLocalStoragePubSub` to automatically sync state with `localStorage`
@@ -35,7 +33,6 @@ Instead of a heavy state management library like Redux, MiniSearch uses a minima
35
  ### Reranker
36
 
37
  A secondary search stage that takes initial results from SearXNG and re-orders them based on relevance to the query using a multilingual cross-encoder model (`mmarco-mMiniLMv2-L12-H384-v1`) running in-process via ONNX Runtime.
38
-
39
  - **Implementation**: Loads the model's ONNX export with `onnxruntime-node`; no child process
40
  - **Health Check**: Polls `/health` endpoint via `getRerankerStatus`
41
  - **Scoring**: Results filtered using standard deviation thresholds (`kStandardDeviationFactor = 0.3`)
@@ -44,7 +41,6 @@ A secondary search stage that takes initial results from SearXNG and re-orders t
44
  ### Wllama
45
 
46
  A WebAssembly (WASM) based integration of `llama.cpp` for running LLMs on the CPU in the browser.
47
-
48
  - **Initialization**: Loads models from HuggingFace using `initializeWllama`
49
  - **Warmup**: Includes a warmup phase with a single token completion using `n_threads: 1`
50
  - **OPFS**: Uses the Origin Private File System via Wllama's cache manager to store model shards locally
@@ -53,7 +49,6 @@ A WebAssembly (WASM) based integration of `llama.cpp` for running LLMs on the CP
53
  ### AI Horde
54
 
55
  A crowdsourced distributed cluster of workers providing AI inference. MiniSearch integrates with it using a polling strategy against the `/generate/text/status` endpoint.
56
-
57
  - **Kudos**: Virtual currency used by the Horde. Default anonymous key is `0000000000`
58
  - **Polling**: Requests sent to async API, status checked periodically until completion
59
  - **Cancellation**: Can abort generation via `DELETE` on the status endpoint
@@ -61,7 +56,6 @@ A crowdsourced distributed cluster of workers providing AI inference. MiniSearch
61
  ### Conversation Memory & Rolling Summary
62
 
63
  A mechanism to handle long chats that exceed the LLM context window.
64
-
65
  - **Summarization**: When older messages are dropped, `createLlmSummary` asks the LLM to condense them under a limit of 800 tokens
66
  - **Extractive Fallback**: If LLM summarization fails, `summarizeDroppedMessages` uses a token-counting extractive approach
67
  - **Token Budget**: Computed based on `openAiContextLength` setting and current message count
@@ -88,16 +82,16 @@ Note the version mismatch trap: Dexie registers its schema at the declared versi
88
 
89
  Middleware registered via Vite plugin hooks (`configureServer`, `configurePreviewServer`). All server-side logic in MiniSearch is implemented as hooks:
90
 
91
- | Hook | Purpose |
92
- |------|---------|
93
- | `compressionServerHook` | gzip/brotli compression |
94
- | `crossOriginServerHook` | COOP/COEP headers for SharedArrayBuffer |
95
- | `searchEndpointServerHook` | `/search/text` and `/search/images` endpoints |
96
- | `statusEndpointServerHook` | `/status` health check |
97
- | `cacheServerHook` | Cache-Control headers |
98
- | `validateAccessKeyServerHook` | Access key validation |
99
- | `internalApiEndpointServerHook` | `/inference` proxy |
100
- | `rerankerServiceHook` | Reranker model lifecycle management |
101
 
102
  ### Circuit Breaker
103
 
@@ -115,30 +109,30 @@ A password hashing algorithm used for access key validation. Client hashes the a
115
 
116
  ### SearchCacheDatabase Schema
117
 
118
- | Store | Primary Key | Indexed Field | Entry Type |
119
- |-------|-------------|---------------|------------|
120
- | `textSearchHistory` | key (hash) | timestamp | TextSearchCache |
121
- | `imageSearchHistory` | key (hash) | timestamp | ImageSearchCache |
122
 
123
  ### HistoryDatabase Schema
124
 
125
- | Table | Purpose |
126
- |-------|---------|
127
- | `searches` | Canonical log of each query with hydrated results payloads |
128
- | `llmResponses` | AI answers tied to their originating search run |
129
- | `chatHistory` | Chronological chat turns scoped by `conversationId` |
130
 
131
  ### PubSub Channel Types
132
 
133
- | Channel | Data Type | Persistence |
134
- |---------|-----------|-------------|
135
- | `queryPubSub` | `string` | Memory |
136
- | `responsePubSub` | `string` | Memory (throttled) |
137
- | `settingsPubSub` | `Settings` | localStorage |
138
- | `textSearchResultsPubSub` | `TextSearchResults` | Memory |
139
- | `textGenerationStatePubSub` | `TextGenerationState` | Memory |
140
- | `chatMessagesPubSub` | `ChatMessage[]` | Memory |
141
- | `conversationSummaryPubSub` | `{id, summary}` | Memory |
142
 
143
  ## Related Topics
144
 
 
7
  ### Search Token & Hash
8
 
9
  A security mechanism used to authorize communication between the client and the internal search/AI endpoints.
 
10
  - **Search Token**: A random string held by the running server, served to the client at runtime through `/api/config`. Used to verify that requests to the server come from a client that server handed the token to.
11
  - **Search Token Hash**: To avoid exposing the raw token in all requests, the client generates a hash of the token. Managed via the `lastSearchTokenHashPubSub` channel.
12
  - **Verification**: The server verifies these tokens to prevent unauthorized access to the search API. Stored in `server/verifiedTokens.ts` as an in-memory `Set<string>`.
 
15
 
16
  MiniSearch supports multiple backends for Large Language Model (LLM) inference, configured via `inferenceType` in the application settings.
17
 
18
+ | Type | Description | Implementation |
19
+ | ---------- | -------------------------------------------------- | --------------------------------------- |
20
+ | `browser` | Local inference using WASM (Wllama) | Client-side, privacy-preserving |
21
+ | `openai` | Connection to any OpenAI-compatible external API | Requires API key |
22
+ | `horde` | Crowdsourced inference via the AI Horde network | Distributed, anonymous or authenticated |
23
+ | `internal` | Server-side proxy using pre-configured credentials | API key hidden from client |
24
 
25
  ### PubSub (State Management)
26
 
27
  Instead of a heavy state management library like Redux, MiniSearch uses a minimalist Publish-Subscribe pattern powered by the `create-pubsub` library.
 
28
  - **Data Flow**: Components subscribe to "channels" (e.g., `queryPubSub`, `responsePubSub`)
29
  - **Tuple Pattern**: Each channel is a 3-element tuple: `[update, subscribe, get]`
30
  - **Persistence**: Some channels use `createLocalStoragePubSub` to automatically sync state with `localStorage`
 
33
  ### Reranker
34
 
35
  A secondary search stage that takes initial results from SearXNG and re-orders them based on relevance to the query using a multilingual cross-encoder model (`mmarco-mMiniLMv2-L12-H384-v1`) running in-process via ONNX Runtime.
 
36
  - **Implementation**: Loads the model's ONNX export with `onnxruntime-node`; no child process
37
  - **Health Check**: Polls `/health` endpoint via `getRerankerStatus`
38
  - **Scoring**: Results filtered using standard deviation thresholds (`kStandardDeviationFactor = 0.3`)
 
41
  ### Wllama
42
 
43
  A WebAssembly (WASM) based integration of `llama.cpp` for running LLMs on the CPU in the browser.
 
44
  - **Initialization**: Loads models from HuggingFace using `initializeWllama`
45
  - **Warmup**: Includes a warmup phase with a single token completion using `n_threads: 1`
46
  - **OPFS**: Uses the Origin Private File System via Wllama's cache manager to store model shards locally
 
49
  ### AI Horde
50
 
51
  A crowdsourced distributed cluster of workers providing AI inference. MiniSearch integrates with it using a polling strategy against the `/generate/text/status` endpoint.
 
52
  - **Kudos**: Virtual currency used by the Horde. Default anonymous key is `0000000000`
53
  - **Polling**: Requests sent to async API, status checked periodically until completion
54
  - **Cancellation**: Can abort generation via `DELETE` on the status endpoint
 
56
  ### Conversation Memory & Rolling Summary
57
 
58
  A mechanism to handle long chats that exceed the LLM context window.
 
59
  - **Summarization**: When older messages are dropped, `createLlmSummary` asks the LLM to condense them under a limit of 800 tokens
60
  - **Extractive Fallback**: If LLM summarization fails, `summarizeDroppedMessages` uses a token-counting extractive approach
61
  - **Token Budget**: Computed based on `openAiContextLength` setting and current message count
 
82
 
83
  Middleware registered via Vite plugin hooks (`configureServer`, `configurePreviewServer`). All server-side logic in MiniSearch is implemented as hooks:
84
 
85
+ | Hook | Purpose |
86
+ | ------------------------------- | --------------------------------------------- |
87
+ | `compressionServerHook` | gzip/brotli compression |
88
+ | `crossOriginServerHook` | COOP/COEP headers for SharedArrayBuffer |
89
+ | `searchEndpointServerHook` | `/search/text` and `/search/images` endpoints |
90
+ | `statusEndpointServerHook` | `/status` health check |
91
+ | `cacheServerHook` | Cache-Control headers |
92
+ | `validateAccessKeyServerHook` | Access key validation |
93
+ | `internalApiEndpointServerHook` | `/inference` proxy |
94
+ | `rerankerServiceHook` | Reranker model lifecycle management |
95
 
96
  ### Circuit Breaker
97
 
 
109
 
110
  ### SearchCacheDatabase Schema
111
 
112
+ | Store | Primary Key | Indexed Field | Entry Type |
113
+ | -------------------- | ----------- | ------------- | ---------------- |
114
+ | `textSearchHistory` | key (hash) | timestamp | TextSearchCache |
115
+ | `imageSearchHistory` | key (hash) | timestamp | ImageSearchCache |
116
 
117
  ### HistoryDatabase Schema
118
 
119
+ | Table | Purpose |
120
+ | -------------- | ---------------------------------------------------------- |
121
+ | `searches` | Canonical log of each query with hydrated results payloads |
122
+ | `llmResponses` | AI answers tied to their originating search run |
123
+ | `chatHistory` | Chronological chat turns scoped by `conversationId` |
124
 
125
  ### PubSub Channel Types
126
 
127
+ | Channel | Data Type | Persistence |
128
+ | --------------------------- | --------------------- | ------------------ |
129
+ | `queryPubSub` | `string` | Memory |
130
+ | `responsePubSub` | `string` | Memory (throttled) |
131
+ | `settingsPubSub` | `Settings` | localStorage |
132
+ | `textSearchResultsPubSub` | `TextSearchResults` | Memory |
133
+ | `textGenerationStatePubSub` | `TextGenerationState` | Memory |
134
+ | `chatMessagesPubSub` | `ChatMessage[]` | Memory |
135
+ | `conversationSummaryPubSub` | `{id, summary}` | Memory |
136
 
137
  ## Related Topics
138
 
docs/overview.md CHANGED
@@ -33,7 +33,6 @@ MiniSearch integrates multiple technology stacks within a unified deployment con
33
  ## Application Entry Points
34
 
35
  The application has three primary entry points:
36
-
37
  1. **Browser Entry**: `client/index.tsx` initializes the React application, mounting the root component and setting up error boundaries.
38
 
39
  2. **Server Entry**: `vite.config.ts` configures the Vite development and preview servers, registering server hooks for search and inference endpoints.
@@ -43,7 +42,6 @@ The application has three primary entry points:
43
  ## Multi-Service Container Architecture
44
 
45
  The Docker container runs three services concurrently:
46
-
47
  - **SearXNG** - Privacy-focused metasearch engine
48
  - **ONNX Runtime** - In-process inference for result reranking
49
  - **Node.js application** - Main application server
@@ -59,7 +57,6 @@ PubSub channels are created using the create-pubsub package and provide type-saf
59
  ## Data Persistence Strategy
60
 
61
  MiniSearch employs a dual-layer persistence approach:
62
-
63
  - **IndexedDB** - Local storage for search history, settings, cached results, and saved AI transcripts
64
  - **TTL-based caching** - 15-minute freshness cache for search results to minimize API calls, with a 24-hour stale retention as a fallback when the live search fails
65
 
@@ -114,7 +111,6 @@ The `textGeneration` module orchestrates the entire search-to-response flow, man
114
  ### Web Search Service Reliability
115
 
116
  `server/webSearchService.ts` implements resilience patterns for SearXNG integration:
117
-
118
  - **Circuit Breaker**: Opens after 5 consecutive failures, blocking requests for 60 seconds before attempting reset
119
  - **Retry Logic**: Exponential backoff for HTTP 500 errors, up to 3 retries
120
  - **Fail-Fast on Suspending Engines**: An empty response in which every unresponsive engine is under a long suspension (CAPTCHA, rate limit, access denied; SearXNG suspends them for an hour to a day) throws on the first attempt instead of spending the retry budget, which it could not possibly outlast. Transient engine errors (timeouts, server API errors) keep the full retry budget.
@@ -126,7 +122,6 @@ None of this changes which engines answer. That is decided inside SearXNG, and w
126
  ### Search Token Lifecycle
127
 
128
  CSRF protection uses a token the server owns for its lifetime:
129
-
130
  1. **Generation**: `regenerateSearchToken()` draws 32 random bytes on first use in a process and writes them to the token file; the file is a record, never read back as a source of truth
131
  2. **Storage**: The token is held in memory for the life of the process, and recorded at `{os.tempdir()}/minisearch-token` with `0600` permissions
132
  3. **Distribution**: Server serves the token to the client at runtime through `/api/config`, so a client always holds the token of the server answering it
@@ -144,7 +139,6 @@ MiniSearch uses a PubSub-based architecture where state flows through independen
144
  ### State Machine Transitions
145
 
146
  **Text Generation States:**
147
-
148
  - `idle` - No active generation
149
  - `awaitingModelDownloadAllowance` - Waiting for user consent to download a browser model
150
  - `loadingModel` - Downloading or initializing the browser (Wllama) model
@@ -156,7 +150,6 @@ MiniSearch uses a PubSub-based architecture where state flows through independen
156
  - `failed` - Error occurred
157
 
158
  **Search States:**
159
-
160
  - `idle` - No active search
161
  - `running` - Search in progress
162
  - `completed` - Results received
@@ -174,10 +167,10 @@ MiniSearch uses a PubSub-based architecture where state flows through independen
174
 
175
  Streaming LLM output produces token-by-token state changes that would overwhelm React's rendering pipeline. Two channels apply throttling via `throttleit`:
176
 
177
- | Channel | Throttle Interval | Purpose |
178
- |---------|-------------------|---------|
179
- | `responsePubSub` | ~83ms (12/sec) | AI response text streaming |
180
- | `reasoningContentPubSub` | ~83ms (12/sec) | Reasoning/thinking content streaming |
181
 
182
  Callers write tokens directly to `updateResponse` or `updateReasoningContent` without awareness of internal throttling.
183
 
@@ -185,11 +178,11 @@ Callers write tokens directly to `updateResponse` or `updateReasoningContent` wi
185
 
186
  Three channels register built-in side-effect subscribers at module load time for automatic logging:
187
 
188
- | Channel | Side Effect |
189
- | --------- | ------------- |
190
  | `textGenerationStatePubSub` | Logs state transitions via `addLogEntry` |
191
- | `textSearchStatePubSub` | Logs state transitions via `addLogEntry` |
192
- | `imageSearchStatePubSub` | Logs state transitions via `addLogEntry` |
193
 
194
  ## Build and Deployment Pipeline
195
 
@@ -199,20 +192,19 @@ The build pipeline uses Biome for linting and formatting, TypeScript for type ch
199
 
200
  MiniSearch implements all server-side logic as Vite plugin hooks. Each hook registers middleware on Vite's HTTP server, working identically in both dev (`vite`) and production preview (`vite preview`) modes. Hooks are declared in `vite.config.ts` and registered via `configureServer`/`configurePreviewServer` callbacks.
201
 
202
- | Hook | File | Purpose |
203
- | ------ | ------ | --------- |
204
- | `compressionServerHook` | `server/compressionServerHook.ts` | gzip/brotli compression for all responses |
205
- | `crossOriginServerHook` | `server/crossOriginServerHook.ts` | COOP/COEP headers for SharedArrayBuffer |
206
- | `searchEndpointServerHook` | `server/searchEndpointServerHook.ts` | `/search/text` and `/search/images` endpoints proxied to SearXNG |
207
  | `pageContentEndpointServerHook` | `server/pageContentEndpointServerHook.ts` | `/page-content` endpoint that reads result pages for answer grounding |
208
- | `statusEndpointServerHook` | `server/statusEndpointServerHook.ts` | `/status` health check endpoint |
209
- | `cacheServerHook` | `server/cacheServerHook.ts` | Cache-Control headers (preview only) |
210
- | `validateAccessKeyServerHook` | `server/validateAccessKeyServerHook.ts` | Access key validation endpoint |
211
- | `internalApiEndpointServerHook` | `server/internalApiEndpointServerHook.ts` | `/inference` proxy to self-hosted API |
212
- | `rerankerServiceHook` | `server/rerankerServiceHook.ts` | Reranker model lifecycle management for result reranking |
213
 
214
  Key server-side modules:
215
-
216
  - **`server/webSearchService.ts`**: Integrates with SearXNG at `http://127.0.0.1:8888`. Implements a circuit breaker (opens after 5 failures, resets after 60s) and retry logic (up to 3 retries with exponential backoff, for 500s and for empty responses naming transiently unresponsive engines; an all-suspended set fails fast on the first attempt, since SearXNG suspensions last an hour to a day).
217
  - **`server/pageContentService.ts`**: Reads result pages for answer grounding: SSRF-guarded fetches with a byte cap, readable-text extraction, and query-relevant passage selection. Reads run under a per-host circuit breaker (`server/pageReadHostBreaker.ts`) that skips a host for 5 minutes after 3 refusals in a row, then probes it with one read.
218
  - **`server/searchToken.ts`**: Manages a token at `{os.tempdir()}/minisearch-token` used for CSRF protection on search requests.
@@ -224,42 +216,42 @@ Key server-side modules:
224
 
225
  The `cacheServerHook` sets Cache-Control headers on every response:
226
 
227
- | Path Pattern | Cache-Control Header | Rationale |
228
- | --- | --- | --- |
229
- | `/assets/*` | `public, max-age=31536000, immutable` | Content-hashed filenames never change |
230
- | `/` or `*.html` | `no-cache` | HTML must always check for updates |
231
- | Everything else | `public, max-age=86400, must-revalidate` | 24-hour cache with revalidation |
232
 
233
  ### Status Endpoint
234
 
235
  The `/status` endpoint returns a JSON object:
236
 
237
- | Field | Type | Description |
238
- | --- | --- | --- |
239
- | `uptime` | string | Human-readable server uptime, measured from `startedAt` |
240
- | `startedAt` | string | ISO timestamp of the server start; all the "since last restart" counters below are measured from this moment |
241
- | `sessions` | number | Distinct verified sessions since last restart, which the two per-session averages below divide by |
242
- | `activeSessions` | number | Sessions still in the cache, dropped after 30 idle minutes |
243
- | `textualSearches` | number | Text search count since last restart |
244
- | `graphicalSearches` | number | Image search count since last restart |
245
- | `averageTextualSearchesPerSession` | number | Text searches / sessions ratio |
246
- | `averageGraphicalSearchesPerSession` | number | Image searches / sessions ratio |
247
- | `searchesWithoutResults` | number | Searches, text and image together, that SearXNG answered with zero results and no unresponsive engines |
248
- | `searchesWithUnresponsiveEngines` | number | Searches, text and image together, that came back with zero results and unresponsive engines, whether the retries were spent or an all-suspended set failed fast; one per search, not per attempt |
249
- | `searchesWithAllResultsDiscarded` | number | Text searches whose results were all dropped during processing |
250
- | `searchesServedByFallback` | number | Text searches answered by the fallback source after SearXNG failed |
251
- | `searchesFailedOnFallback` | number | Fallback searches that failed, plus searches where SearXNG's own retries had left too little of the search deadline for the fallback to be asked at all. A rising count is therefore not by itself a sign that the fallback providers are failing |
252
- | `biEncoderServiceStatus` | string | `"healthy"` or `"unhealthy"` |
253
- | `rerankerServiceStatus` | string | `"healthy"` or `"unhealthy"` |
254
- | `webSearchServiceStatus` | string | `"healthy"`, `"degraded"` or `"unhealthy"`, see below |
255
- | `pageReads` | object | Page-reading counters since last restart, see below |
256
- | `authorization` | object | Token and rate-limit outcomes since last restart, see below |
257
- | `inference` | object | AI answer counters since last restart, see below |
258
- | `searches` | object | Search timing, circuit state and per-engine failures, see below |
259
- | `reranker` | object | Reranking cost and effect, see below |
260
- | `thumbnails` | object | `/thumbnail` request outcomes, see below |
261
- | `build.timestamp` | string | ISO 8601 build time |
262
- | `build.gitCommit` | string | Short Git commit hash |
263
 
264
  `webSearchServiceStatus` answers whether searches can be served, which
265
  SearXNG's `/healthz` alone does not: an open circuit fails every search without
@@ -301,27 +293,27 @@ loaded is a later, per-tile failure, handled when the client fetches it from
301
  is attached to a constant that someone will want to move, which is the reason it
302
  is counted at all (see `docs/page-content.md`):
303
 
304
- | Field | Type | Tunes |
305
- | --- | --- | --- |
306
- | `requested` | number | Nothing; the denominator for the rest |
307
- | `read` | number | Nothing; how often the feature contributed anything |
308
- | `readRate` | number | Nothing; `read` as a percentage of `requested` |
309
- | `averageReadMs` | number | `REQUEST_TIMEOUT_MS`, including the reads that hit it |
310
- | `bodiesTruncated` | number | `MAX_RESPONSE_BYTES` |
311
- | `excerptKeptRate` | number | `MAX_PAGE_CHARS` and the 0.9 dedup threshold, as the share of pooled passages that survive |
312
- | `circuitOpens` | number | `failureThreshold` on the page-read host breaker: how often a host was boxed, summed over every host, since no host is named |
313
- | `skipped.blocked` | number | The SSRF guard, and how often callers aim at private space |
314
- | `skipped.skippedByBreaker` | number | `resetTimeout` on the host breaker: reads refused on the server because the host had refused the last three, which is what the box saved |
315
- | `skipped.notADocument` | number | `READABLE_CONTENT_TYPES` |
316
- | `skipped.httpForbidden` | number | Bot walls and rate limits (401, 403, 429) |
317
- | `skipped.httpNotFound` | number | Dead links (404, 410) |
318
- | `skipped.httpOtherError` | number | Other non-ok HTTP statuses |
319
- | `skipped.redirectLimit` | number | `MAX_REDIRECTS` |
320
- | `skipped.timedOut` | number | `REQUEST_TIMEOUT_MS` |
321
- | `skipped.tooLittleText` | number | `MIN_USEFUL_CHARS`, and the extractor's selectors |
322
- | `skipped.failed` | number | Nothing; the residue worth watching for a pattern |
323
- | `grounding.requests` | number | Nothing; the denominator for the two below |
324
- | `grounding.withContent` | number | Nothing; requests where at least one page yielded text |
325
  | `grounding.withoutContent` | number | The budgets and timeouts above, read per request instead of per page: six pages at a 50% read rate can be three fully grounded answers or six half-grounded ones |
326
 
327
  `read` plus every `skipped` entry sums to `requested`, so a page that goes
@@ -337,18 +329,18 @@ nobody asked for an answer, not that nothing could be read.
337
  verification, the funnel `/search/text`, `/search/images`, `/page-content` and
338
  `/inference` all pass through:
339
 
340
- | Field | Type | Says |
341
- | --- | --- | --- |
342
- | `requests` | number | Requests that reached verification; the denominator for the rest |
343
- | `authorized` | number | Requests that passed it |
344
- | `rejectedRate` | number | Rejections as a percentage of `requests` |
345
- | `reasons.rateLimited` | number | Refused by the limiter before anything else ran |
346
- | `reasons.missingToken` | number | No token on the request, which is what an outdated client looks like |
347
- | `reasons.invalidToken` | number | A token that failed verification, which is what probing looks like |
348
- | `bySurface` | object | `authorized` and `rejected` per endpoint family: `search`, `pageContent`, `thumbnail`, `inference`, `other` |
349
- | `rejectedTokenCacheHits` | number | Rejections served from the rejected-token cache without a second argon2 verification |
350
  | `distinctRejectedTokens` | number | Distinct tokens the rejected-token cache holds, at most 1024. Only well-formed tokens that failed argon2 verification enter it; a token without the expected hash prefix is refused before that and never counted. A high `reasons.invalidToken` with a value of 1 here is one client replaying one dead token. A value that keeps growing is either many stale links or one caller sending fresh junk hashes, which the limiter caps per IP. The set only empties on a restart and evicts its oldest entry when full, so a value of 1024 means 1024 or more |
351
- | `limiter` | object | The shared limiter's `points` and `durationSeconds`, plus the separate `thumbnail` budget behind `/thumbnail`, without which a rejection count says nothing |
352
 
353
  `authorized` plus every entry of `reasons` sums to `requests`, and each half of
354
  `bySurface` sums to its side of that, on the same principle as `pageReads`.
@@ -368,27 +360,27 @@ pays for it running out.
368
  `/inference`. Nothing about the conversation is kept, so the questions it can
369
  answer are about the upstream and about the wait, not about what was asked:
370
 
371
- | Field | Type | Says |
372
- | --- | --- | --- |
373
- | `requests` | number | Requests that passed token verification; the denominator for the rest |
374
- | `streamed` | number | Answers that finished normally |
375
- | `streamedRate` | number | `streamed` as a percentage of `requests` |
376
- | `averageTimeToFirstTokenMs` | number | The wait the user actually feels, over the answers that produced a token |
377
- | `averageDurationMs` | number | Whole-request duration over every outcome, so a 2 ms rejected body and a 25 s answer land in the same average |
378
- | `averageAttempts` | number | Models tried, over the requests that reached the model loop; above 1 means the pool is carrying failures |
379
- | `modelFallbacks` | number | Times a retry moved to another model |
380
- | `modelsRefetched` | number | Times the model pool was re-listed mid-retry |
381
- | `streamsEndedWithoutFinish` | number | Attempts whose stream closed with no finish part, counted per attempt because the retry can still succeed |
382
- | `failed.failedBeforeFirstToken` | number | Every model failed before a token was sent, answered 503 |
383
- | `failed.failedMidStream` | number | The answer broke after content was already sent, so no retry was possible |
384
- | `failed.abandoned` | number | The client was gone before or during the answer; the only outcome with no response to observe it by |
385
- | `failed.badRequest` | number | Body too large, unparsable, or failing the schema |
386
- | `failed.notConfigured` | number | `INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL` or its key is missing |
387
- | `failed.modelListUnavailable` | number | The upstream's model list could not be fetched |
388
- | `failed.noModelAvailable` | number | The list was fetched and held nothing usable |
389
- | `failed.internalError` | number | Anything the handler did not expect |
390
- | `failed.unclassified` | number | Should always be zero; a non-zero value means a path stopped reporting its outcome |
391
- | `byModel` | object | `attempted`, `streamed`, `failed` and `abandoned` per model id, which is how a dead member of the pool becomes visible. `attempted` equals the other three added up |
392
 
393
  `streamed` plus every `failed` entry sums to `requests`. A request refused at
394
  token verification is not counted here, since `authorization` above already
@@ -412,27 +404,27 @@ nothing new.
412
  in the search path. Each row names the constant it is there to move, the same
413
  way `pageReads` does:
414
 
415
- | Field | Type | Tunes |
416
- | --- | --- | --- |
417
- | `searches.averageTextualMs` | number | The 30 s client timeout in `client/modules/search.ts`. Over the text searches SearXNG answered, so a failed or short-circuited one is not in it, and up to 7 s of retry backoff is |
418
- | `searches.averageGraphicalMs` | number | The same, for image searches |
419
- | `reranker.considered` | number | Nothing; results handed to the score filter, the denominator for `keptRate` |
420
- | `reranker.kept` | number | Nothing; results that survived it |
421
- | `searches.circuitState` | string | Nothing; whether searches are being short-circuited right now |
422
- | `searches.degradedSearchTypes` | array | Nothing; which of `text` and `images` is currently flagged, so a `degraded` verdict says which engine pool it is about. Empty when the half-open circuit is what triggered the verdict, which `circuitState` in the same object names |
423
- | `searches.circuitOpens` | number | `failureThreshold` and `resetTimeout` on the SearXNG breaker: each opening is a minute of serving no searches at all |
424
- | `searches.unresponsiveEngines` | object | Nothing; `failures` and `lastFailure` per engine name, which is how one engine timing out is told apart from every engine being CAPTCHA-blocked. `lastFailure` is `blocked` (a CAPTCHA, a rate limit or an access denial), `timeout` or `other`, never SearXNG's own wording, which is free-form text from an upstream engine and does not belong on an endpoint that needs no token. Counted on the searches `searchesWithUnresponsiveEngines` counts, so a search that recovered on a retry is not in it |
425
- | `searches.fallbackProviders` | object | Whether each fallback provider earns its place, and whether one needs an API key: per provider name, `served`, `empty`, `failed`, `rateLimited` (answered `429`, and also counted in `failed`) and `skippedWhilePaused` counts, plus `pausedForMs`, the time left on its current `429` pause (`0` when not paused). A steady `rateLimited` count on a keyless provider is the cue to set its key (see `docs/configuration.md`). Provider names are configured infrastructure, not user input |
426
- | `reranker.reranks` | number | Nothing; the denominator for the rest |
427
- | `reranker.averageMs` | number | Whether reranking or SearXNG is what users wait for, and so whether to rerank a shortlist instead of all 30 results. Over `reranks`, covering the filtering and sorting as well as the model |
428
- | `reranker.keptRate` | number | `kStandardDeviationFactor`: near 100% means the filter is not filtering. Text and image reranks are pooled, and the two run through different paths, so this moves with the traffic mix as well as with the threshold |
429
- | `reranker.byType.text`, `reranker.byType.images` | object | Each reports `reranks`, `averageMs`, `considered`, `kept` and `keptRate` for its search type; see `docs/reranking.md`. The existing top-level values remain combined totals |
430
- | `reranker.fallbackApplied` | number | `minPercentageFallback`: a large share means the deviation threshold is emptying batches the fallback then has to rescue |
431
- | `reranker.skippedUnhealthy` | number | Nothing; searches served in SearXNG's own order because the model was not loaded |
432
- | `reranker.failed` | number | Nothing; the same, because reranking threw |
433
- | `thumbnails.requested` | number | Nothing; the denominator for the two below; every verified `/thumbnail` request, cache hits included. Before the endpoint existed this counted image results in a search response, so the served share cannot be trended across that deploy |
434
- | `thumbnails.dropped` | number | Every request that did not serve a tile: timeout, a non-raster or empty answer, or an address the guard refused (`MAX_THUMBNAIL_BYTES` is not one of these, a capped body is truncated and served): each is a tile the user saw as a placeholder |
435
- | `thumbnails.blocked` | number | The SSRF guard, as the share of thumbnails whose host is in private space or does not resolve |
436
 
437
  `thumbnails.dropped` includes the blocked ones, so `requested` minus `dropped`
438
  is what reached the client.
@@ -444,22 +436,20 @@ MiniSearch uses a multi-layered client-side persistence strategy:
444
  ### IndexedDB Databases
445
 
446
  Two separate Dexie databases handle different persistence needs:
447
-
448
  1. **SearchCacheDatabase** (`client/modules/search.ts`): Temporary cache for search results with TTL-based expiration. Table schema:
449
  - `textSearchHistory`: Keyed by hashed query, indexed by timestamp
450
  - `imageSearchHistory`: Keyed by hashed query, indexed by timestamp
451
  - Cache config:
452
 
453
- | Constant | Value | Description |
454
- | ---------- | ------- | ------------- |
455
- | TTL | 15 minutes | Freshness window: a hit inside it skips the network |
456
- | MAX_STALE_RETENTION | 24 hours | How long an expired entry is kept, so a failed live search can still serve it flagged stale |
457
- | MAX_ENTRIES | 100 | Maximum cached queries per store |
458
- | ENABLED | true | Global cache toggle |
459
- | PRUNE_INTERVAL | 10 | Cache writes between LRU prune passes |
460
- | METRICS_LOG_INTERVAL | 10 | Operations between hit-rate log entries |
461
- | REQUEST_TIMEOUT | 30,000 ms | Fetch timeout |
462
-
463
  - Query hashing: djb2 XOR Murmur algorithm for cache key generation
464
  - Management operations: `cleanExpiredCache`, `pruneCache`, `ensureIntegrity`
465
  - Performance monitoring: `cacheMetrics` tracks hit/miss rates for text and image searches
@@ -473,7 +463,6 @@ Two separate Dexie databases handle different persistence needs:
473
  ### localStorage Persistence
474
 
475
  Lightweight state persisted across sessions via `createLocalStoragePubSub` pattern:
476
-
477
  - `settings`: Application preferences (inference type, model, UI options)
478
  - `querySuggestions`: Shuffled search suggestion pool
479
  - `lastSearchTokenHash`: Cached security token hash
 
33
  ## Application Entry Points
34
 
35
  The application has three primary entry points:
 
36
  1. **Browser Entry**: `client/index.tsx` initializes the React application, mounting the root component and setting up error boundaries.
37
 
38
  2. **Server Entry**: `vite.config.ts` configures the Vite development and preview servers, registering server hooks for search and inference endpoints.
 
42
  ## Multi-Service Container Architecture
43
 
44
  The Docker container runs three services concurrently:
 
45
  - **SearXNG** - Privacy-focused metasearch engine
46
  - **ONNX Runtime** - In-process inference for result reranking
47
  - **Node.js application** - Main application server
 
57
  ## Data Persistence Strategy
58
 
59
  MiniSearch employs a dual-layer persistence approach:
 
60
  - **IndexedDB** - Local storage for search history, settings, cached results, and saved AI transcripts
61
  - **TTL-based caching** - 15-minute freshness cache for search results to minimize API calls, with a 24-hour stale retention as a fallback when the live search fails
62
 
 
111
  ### Web Search Service Reliability
112
 
113
  `server/webSearchService.ts` implements resilience patterns for SearXNG integration:
 
114
  - **Circuit Breaker**: Opens after 5 consecutive failures, blocking requests for 60 seconds before attempting reset
115
  - **Retry Logic**: Exponential backoff for HTTP 500 errors, up to 3 retries
116
  - **Fail-Fast on Suspending Engines**: An empty response in which every unresponsive engine is under a long suspension (CAPTCHA, rate limit, access denied; SearXNG suspends them for an hour to a day) throws on the first attempt instead of spending the retry budget, which it could not possibly outlast. Transient engine errors (timeouts, server API errors) keep the full retry budget.
 
122
  ### Search Token Lifecycle
123
 
124
  CSRF protection uses a token the server owns for its lifetime:
 
125
  1. **Generation**: `regenerateSearchToken()` draws 32 random bytes on first use in a process and writes them to the token file; the file is a record, never read back as a source of truth
126
  2. **Storage**: The token is held in memory for the life of the process, and recorded at `{os.tempdir()}/minisearch-token` with `0600` permissions
127
  3. **Distribution**: Server serves the token to the client at runtime through `/api/config`, so a client always holds the token of the server answering it
 
139
  ### State Machine Transitions
140
 
141
  **Text Generation States:**
 
142
  - `idle` - No active generation
143
  - `awaitingModelDownloadAllowance` - Waiting for user consent to download a browser model
144
  - `loadingModel` - Downloading or initializing the browser (Wllama) model
 
150
  - `failed` - Error occurred
151
 
152
  **Search States:**
 
153
  - `idle` - No active search
154
  - `running` - Search in progress
155
  - `completed` - Results received
 
167
 
168
  Streaming LLM output produces token-by-token state changes that would overwhelm React's rendering pipeline. Two channels apply throttling via `throttleit`:
169
 
170
+ | Channel | Throttle Interval | Purpose |
171
+ | ------------------------ | ----------------- | ------------------------------------ |
172
+ | `responsePubSub` | ~83ms (12/sec) | AI response text streaming |
173
+ | `reasoningContentPubSub` | ~83ms (12/sec) | Reasoning/thinking content streaming |
174
 
175
  Callers write tokens directly to `updateResponse` or `updateReasoningContent` without awareness of internal throttling.
176
 
 
178
 
179
  Three channels register built-in side-effect subscribers at module load time for automatic logging:
180
 
181
+ | Channel | Side Effect |
182
+ | --------------------------- | ---------------------------------------- |
183
  | `textGenerationStatePubSub` | Logs state transitions via `addLogEntry` |
184
+ | `textSearchStatePubSub` | Logs state transitions via `addLogEntry` |
185
+ | `imageSearchStatePubSub` | Logs state transitions via `addLogEntry` |
186
 
187
  ## Build and Deployment Pipeline
188
 
 
192
 
193
  MiniSearch implements all server-side logic as Vite plugin hooks. Each hook registers middleware on Vite's HTTP server, working identically in both dev (`vite`) and production preview (`vite preview`) modes. Hooks are declared in `vite.config.ts` and registered via `configureServer`/`configurePreviewServer` callbacks.
194
 
195
+ | Hook | File | Purpose |
196
+ | ------------------------------- | ----------------------------------------- | --------------------------------------------------------------------- |
197
+ | `compressionServerHook` | `server/compressionServerHook.ts` | gzip/brotli compression for all responses |
198
+ | `crossOriginServerHook` | `server/crossOriginServerHook.ts` | COOP/COEP headers for SharedArrayBuffer |
199
+ | `searchEndpointServerHook` | `server/searchEndpointServerHook.ts` | `/search/text` and `/search/images` endpoints proxied to SearXNG |
200
  | `pageContentEndpointServerHook` | `server/pageContentEndpointServerHook.ts` | `/page-content` endpoint that reads result pages for answer grounding |
201
+ | `statusEndpointServerHook` | `server/statusEndpointServerHook.ts` | `/status` health check endpoint |
202
+ | `cacheServerHook` | `server/cacheServerHook.ts` | Cache-Control headers (preview only) |
203
+ | `validateAccessKeyServerHook` | `server/validateAccessKeyServerHook.ts` | Access key validation endpoint |
204
+ | `internalApiEndpointServerHook` | `server/internalApiEndpointServerHook.ts` | `/inference` proxy to self-hosted API |
205
+ | `rerankerServiceHook` | `server/rerankerServiceHook.ts` | Reranker model lifecycle management for result reranking |
206
 
207
  Key server-side modules:
 
208
  - **`server/webSearchService.ts`**: Integrates with SearXNG at `http://127.0.0.1:8888`. Implements a circuit breaker (opens after 5 failures, resets after 60s) and retry logic (up to 3 retries with exponential backoff, for 500s and for empty responses naming transiently unresponsive engines; an all-suspended set fails fast on the first attempt, since SearXNG suspensions last an hour to a day).
209
  - **`server/pageContentService.ts`**: Reads result pages for answer grounding: SSRF-guarded fetches with a byte cap, readable-text extraction, and query-relevant passage selection. Reads run under a per-host circuit breaker (`server/pageReadHostBreaker.ts`) that skips a host for 5 minutes after 3 refusals in a row, then probes it with one read.
210
  - **`server/searchToken.ts`**: Manages a token at `{os.tempdir()}/minisearch-token` used for CSRF protection on search requests.
 
216
 
217
  The `cacheServerHook` sets Cache-Control headers on every response:
218
 
219
+ | Path Pattern | Cache-Control Header | Rationale |
220
+ | --------------- | ---------------------------------------- | ------------------------------------- |
221
+ | `/assets/*` | `public, max-age=31536000, immutable` | Content-hashed filenames never change |
222
+ | `/` or `*.html` | `no-cache` | HTML must always check for updates |
223
+ | Everything else | `public, max-age=86400, must-revalidate` | 24-hour cache with revalidation |
224
 
225
  ### Status Endpoint
226
 
227
  The `/status` endpoint returns a JSON object:
228
 
229
+ | Field | Type | Description |
230
+ | ------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
231
+ | `uptime` | string | Human-readable server uptime, measured from `startedAt` |
232
+ | `startedAt` | string | ISO timestamp of the server start; all the "since last restart" counters below are measured from this moment |
233
+ | `sessions` | number | Distinct verified sessions since last restart, which the two per-session averages below divide by |
234
+ | `activeSessions` | number | Sessions still in the cache, dropped after 30 idle minutes |
235
+ | `textualSearches` | number | Text search count since last restart |
236
+ | `graphicalSearches` | number | Image search count since last restart |
237
+ | `averageTextualSearchesPerSession` | number | Text searches / sessions ratio |
238
+ | `averageGraphicalSearchesPerSession` | number | Image searches / sessions ratio |
239
+ | `searchesWithoutResults` | number | Searches, text and image together, that SearXNG answered with zero results and no unresponsive engines |
240
+ | `searchesWithUnresponsiveEngines` | number | Searches, text and image together, that came back with zero results and unresponsive engines, whether the retries were spent or an all-suspended set failed fast; one per search, not per attempt |
241
+ | `searchesWithAllResultsDiscarded` | number | Text searches whose results were all dropped during processing |
242
+ | `searchesServedByFallback` | number | Text searches answered by the fallback source after SearXNG failed |
243
+ | `searchesFailedOnFallback` | number | Fallback searches that failed, plus searches where SearXNG's own retries had left too little of the search deadline for the fallback to be asked at all. A rising count is therefore not by itself a sign that the fallback providers are failing |
244
+ | `biEncoderServiceStatus` | string | `"healthy"` or `"unhealthy"` |
245
+ | `rerankerServiceStatus` | string | `"healthy"` or `"unhealthy"` |
246
+ | `webSearchServiceStatus` | string | `"healthy"`, `"degraded"` or `"unhealthy"`, see below |
247
+ | `pageReads` | object | Page-reading counters since last restart, see below |
248
+ | `authorization` | object | Token and rate-limit outcomes since last restart, see below |
249
+ | `inference` | object | AI answer counters since last restart, see below |
250
+ | `searches` | object | Search timing, circuit state and per-engine failures, see below |
251
+ | `reranker` | object | Reranking cost and effect, see below |
252
+ | `thumbnails` | object | `/thumbnail` request outcomes, see below |
253
+ | `build.timestamp` | string | ISO 8601 build time |
254
+ | `build.gitCommit` | string | Short Git commit hash |
255
 
256
  `webSearchServiceStatus` answers whether searches can be served, which
257
  SearXNG's `/healthz` alone does not: an open circuit fails every search without
 
293
  is attached to a constant that someone will want to move, which is the reason it
294
  is counted at all (see `docs/page-content.md`):
295
 
296
+ | Field | Type | Tunes |
297
+ | -------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
298
+ | `requested` | number | Nothing; the denominator for the rest |
299
+ | `read` | number | Nothing; how often the feature contributed anything |
300
+ | `readRate` | number | Nothing; `read` as a percentage of `requested` |
301
+ | `averageReadMs` | number | `REQUEST_TIMEOUT_MS`, including the reads that hit it |
302
+ | `bodiesTruncated` | number | `MAX_RESPONSE_BYTES` |
303
+ | `excerptKeptRate` | number | `MAX_PAGE_CHARS` and the 0.9 dedup threshold, as the share of pooled passages that survive |
304
+ | `circuitOpens` | number | `failureThreshold` on the page-read host breaker: how often a host was boxed, summed over every host, since no host is named |
305
+ | `skipped.blocked` | number | The SSRF guard, and how often callers aim at private space |
306
+ | `skipped.skippedByBreaker` | number | `resetTimeout` on the host breaker: reads refused on the server because the host had refused the last three, which is what the box saved |
307
+ | `skipped.notADocument` | number | `READABLE_CONTENT_TYPES` |
308
+ | `skipped.httpForbidden` | number | Bot walls and rate limits (401, 403, 429) |
309
+ | `skipped.httpNotFound` | number | Dead links (404, 410) |
310
+ | `skipped.httpOtherError` | number | Other non-ok HTTP statuses |
311
+ | `skipped.redirectLimit` | number | `MAX_REDIRECTS` |
312
+ | `skipped.timedOut` | number | `REQUEST_TIMEOUT_MS` |
313
+ | `skipped.tooLittleText` | number | `MIN_USEFUL_CHARS`, and the extractor's selectors |
314
+ | `skipped.failed` | number | Nothing; the residue worth watching for a pattern |
315
+ | `grounding.requests` | number | Nothing; the denominator for the two below |
316
+ | `grounding.withContent` | number | Nothing; requests where at least one page yielded text |
317
  | `grounding.withoutContent` | number | The budgets and timeouts above, read per request instead of per page: six pages at a 50% read rate can be three fully grounded answers or six half-grounded ones |
318
 
319
  `read` plus every `skipped` entry sums to `requested`, so a page that goes
 
329
  verification, the funnel `/search/text`, `/search/images`, `/page-content` and
330
  `/inference` all pass through:
331
 
332
+ | Field | Type | Says |
333
+ | ------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
334
+ | `requests` | number | Requests that reached verification; the denominator for the rest |
335
+ | `authorized` | number | Requests that passed it |
336
+ | `rejectedRate` | number | Rejections as a percentage of `requests` |
337
+ | `reasons.rateLimited` | number | Refused by the limiter before anything else ran |
338
+ | `reasons.missingToken` | number | No token on the request, which is what an outdated client looks like |
339
+ | `reasons.invalidToken` | number | A token that failed verification, which is what probing looks like |
340
+ | `bySurface` | object | `authorized` and `rejected` per endpoint family: `search`, `pageContent`, `thumbnail`, `inference`, `other` |
341
+ | `rejectedTokenCacheHits` | number | Rejections served from the rejected-token cache without a second argon2 verification |
342
  | `distinctRejectedTokens` | number | Distinct tokens the rejected-token cache holds, at most 1024. Only well-formed tokens that failed argon2 verification enter it; a token without the expected hash prefix is refused before that and never counted. A high `reasons.invalidToken` with a value of 1 here is one client replaying one dead token. A value that keeps growing is either many stale links or one caller sending fresh junk hashes, which the limiter caps per IP. The set only empties on a restart and evicts its oldest entry when full, so a value of 1024 means 1024 or more |
343
+ | `limiter` | object | The shared limiter's `points` and `durationSeconds`, plus the separate `thumbnail` budget behind `/thumbnail`, without which a rejection count says nothing |
344
 
345
  `authorized` plus every entry of `reasons` sums to `requests`, and each half of
346
  `bySurface` sums to its side of that, on the same principle as `pageReads`.
 
360
  `/inference`. Nothing about the conversation is kept, so the questions it can
361
  answer are about the upstream and about the wait, not about what was asked:
362
 
363
+ | Field | Type | Says |
364
+ | ------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
365
+ | `requests` | number | Requests that passed token verification; the denominator for the rest |
366
+ | `streamed` | number | Answers that finished normally |
367
+ | `streamedRate` | number | `streamed` as a percentage of `requests` |
368
+ | `averageTimeToFirstTokenMs` | number | The wait the user actually feels, over the answers that produced a token |
369
+ | `averageDurationMs` | number | Whole-request duration over every outcome, so a 2 ms rejected body and a 25 s answer land in the same average |
370
+ | `averageAttempts` | number | Models tried, over the requests that reached the model loop; above 1 means the pool is carrying failures |
371
+ | `modelFallbacks` | number | Times a retry moved to another model |
372
+ | `modelsRefetched` | number | Times the model pool was re-listed mid-retry |
373
+ | `streamsEndedWithoutFinish` | number | Attempts whose stream closed with no finish part, counted per attempt because the retry can still succeed |
374
+ | `failed.failedBeforeFirstToken` | number | Every model failed before a token was sent, answered 503 |
375
+ | `failed.failedMidStream` | number | The answer broke after content was already sent, so no retry was possible |
376
+ | `failed.abandoned` | number | The client was gone before or during the answer; the only outcome with no response to observe it by |
377
+ | `failed.badRequest` | number | Body too large, unparsable, or failing the schema |
378
+ | `failed.notConfigured` | number | `INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL` or its key is missing |
379
+ | `failed.modelListUnavailable` | number | The upstream's model list could not be fetched |
380
+ | `failed.noModelAvailable` | number | The list was fetched and held nothing usable |
381
+ | `failed.internalError` | number | Anything the handler did not expect |
382
+ | `failed.unclassified` | number | Should always be zero; a non-zero value means a path stopped reporting its outcome |
383
+ | `byModel` | object | `attempted`, `streamed`, `failed` and `abandoned` per model id, which is how a dead member of the pool becomes visible. `attempted` equals the other three added up |
384
 
385
  `streamed` plus every `failed` entry sums to `requests`. A request refused at
386
  token verification is not counted here, since `authorization` above already
 
404
  in the search path. Each row names the constant it is there to move, the same
405
  way `pageReads` does:
406
 
407
+ | Field | Type | Tunes |
408
+ | ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
409
+ | `searches.averageTextualMs` | number | The 30 s client timeout in `client/modules/search.ts`. Over the text searches SearXNG answered, so a failed or short-circuited one is not in it, and up to 7 s of retry backoff is |
410
+ | `searches.averageGraphicalMs` | number | The same, for image searches |
411
+ | `reranker.considered` | number | Nothing; results handed to the score filter, the denominator for `keptRate` |
412
+ | `reranker.kept` | number | Nothing; results that survived it |
413
+ | `searches.circuitState` | string | Nothing; whether searches are being short-circuited right now |
414
+ | `searches.degradedSearchTypes` | array | Nothing; which of `text` and `images` is currently flagged, so a `degraded` verdict says which engine pool it is about. Empty when the half-open circuit is what triggered the verdict, which `circuitState` in the same object names |
415
+ | `searches.circuitOpens` | number | `failureThreshold` and `resetTimeout` on the SearXNG breaker: each opening is a minute of serving no searches at all |
416
+ | `searches.unresponsiveEngines` | object | Nothing; `failures` and `lastFailure` per engine name, which is how one engine timing out is told apart from every engine being CAPTCHA-blocked. `lastFailure` is `blocked` (a CAPTCHA, a rate limit or an access denial), `timeout` or `other`, never SearXNG's own wording, which is free-form text from an upstream engine and does not belong on an endpoint that needs no token. Counted on the searches `searchesWithUnresponsiveEngines` counts, so a search that recovered on a retry is not in it |
417
+ | `searches.fallbackProviders` | object | Whether each fallback provider earns its place, and whether one needs an API key: per provider name, `served`, `empty`, `failed`, `rateLimited` (answered `429`, and also counted in `failed`) and `skippedWhilePaused` counts, plus `pausedForMs`, the time left on its current `429` pause (`0` when not paused). A steady `rateLimited` count on a keyless provider is the cue to set its key (see `docs/configuration.md`). Provider names are configured infrastructure, not user input |
418
+ | `reranker.reranks` | number | Nothing; the denominator for the rest |
419
+ | `reranker.averageMs` | number | Whether reranking or SearXNG is what users wait for, and so whether to rerank a shortlist instead of all 30 results. Over `reranks`, covering the filtering and sorting as well as the model |
420
+ | `reranker.keptRate` | number | `kStandardDeviationFactor`: near 100% means the filter is not filtering. Text and image reranks are pooled, and the two run through different paths, so this moves with the traffic mix as well as with the threshold |
421
+ | `reranker.byType.text`, `reranker.byType.images` | object | Each reports `reranks`, `averageMs`, `considered`, `kept` and `keptRate` for its search type; see `docs/reranking.md`. The existing top-level values remain combined totals |
422
+ | `reranker.fallbackApplied` | number | `minPercentageFallback`: a large share means the deviation threshold is emptying batches the fallback then has to rescue |
423
+ | `reranker.skippedUnhealthy` | number | Nothing; searches served in SearXNG's own order because the model was not loaded |
424
+ | `reranker.failed` | number | Nothing; the same, because reranking threw |
425
+ | `thumbnails.requested` | number | Nothing; the denominator for the two below; every verified `/thumbnail` request, cache hits included. Before the endpoint existed this counted image results in a search response, so the served share cannot be trended across that deploy |
426
+ | `thumbnails.dropped` | number | Every request that did not serve a tile: timeout, a non-raster or empty answer, or an address the guard refused (`MAX_THUMBNAIL_BYTES` is not one of these, a capped body is truncated and served): each is a tile the user saw as a placeholder |
427
+ | `thumbnails.blocked` | number | The SSRF guard, as the share of thumbnails whose host is in private space or does not resolve |
428
 
429
  `thumbnails.dropped` includes the blocked ones, so `requested` minus `dropped`
430
  is what reached the client.
 
436
  ### IndexedDB Databases
437
 
438
  Two separate Dexie databases handle different persistence needs:
 
439
  1. **SearchCacheDatabase** (`client/modules/search.ts`): Temporary cache for search results with TTL-based expiration. Table schema:
440
  - `textSearchHistory`: Keyed by hashed query, indexed by timestamp
441
  - `imageSearchHistory`: Keyed by hashed query, indexed by timestamp
442
  - Cache config:
443
 
444
+ | Constant | Value | Description |
445
+ | -------------------- | ---------- | ------------------------------------------------------------------------------------------- |
446
+ | TTL | 15 minutes | Freshness window: a hit inside it skips the network |
447
+ | MAX_STALE_RETENTION | 24 hours | How long an expired entry is kept, so a failed live search can still serve it flagged stale |
448
+ | MAX_ENTRIES | 100 | Maximum cached queries per store |
449
+ | ENABLED | true | Global cache toggle |
450
+ | PRUNE_INTERVAL | 10 | Cache writes between LRU prune passes |
451
+ | METRICS_LOG_INTERVAL | 10 | Operations between hit-rate log entries |
452
+ | REQUEST_TIMEOUT | 30,000 ms | Fetch timeout |
 
453
  - Query hashing: djb2 XOR Murmur algorithm for cache key generation
454
  - Management operations: `cleanExpiredCache`, `pruneCache`, `ensureIntegrity`
455
  - Performance monitoring: `cacheMetrics` tracks hit/miss rates for text and image searches
 
463
  ### localStorage Persistence
464
 
465
  Lightweight state persisted across sessions via `createLocalStoragePubSub` pattern:
 
466
  - `settings`: Application preferences (inference type, model, UI options)
467
  - `querySuggestions`: Shuffled search suggestion pool
468
  - `lastSearchTokenHash`: Cached security token hash
docs/page-content.md CHANGED
@@ -10,8 +10,8 @@ passages that match the query into the prompt.
10
 
11
  One switch controls the feature, and it is on by default:
12
 
13
- | Switch | Who sets it | What it does |
14
- |---|---|---|
15
  | **Read Page Content** | The user, under AI Settings | Whether this browser reads the pages behind its results |
16
 
17
  The endpoint is always available; the user toggle decides whether each browser
@@ -145,12 +145,12 @@ the passage about how long cats sleep.
145
  The interface is in English; the searches are not. Ranking therefore avoids
146
  anything written for one language:
147
 
148
- | Concern | How it is handled | Why not the obvious thing |
149
- | --- | --- | --- |
150
- | Word boundaries | `Intl.Segmenter`, word granularity | `[^\p{L}\p{N}]+` cut Brahmic scripts and Thai at every combining vowel mark and dropped the marks, and it finds no boundary at all in Chinese, Japanese or Thai, which write none: a whole sentence arrived as one token that matched nothing, scoring fell through to document order, and the page's own navigation won |
151
- | Sentence boundaries | `Intl.Segmenter`, sentence granularity | `[.!?]` never matches `ใ€‚`, `๏ผ`, `๏ผŸ` or `เฅค`, so long CJK and Indic blocks were cut at an arbitrary character mid-sentence |
152
- | Uninformative words | Inverse document frequency over the pooled passages of all pages read for the search | A stop-word list only covers the language it was written in. A term found in most passages of a pool cannot say which passage to quote, in any language, and this needs no word list and no language detection |
153
- | Inflection | A query term and a page word match when one is a prefix of the other, from 3 characters and within 3 | Stripping `-ing`, `-es`, `-s` is English morphology applied to every language: it turned the Portuguese `mรฃes` into `mรฃ` and never matched `mรฃe` |
154
 
155
  Both segmenters are script-driven, so the locale is left unset and the result
156
  is the same on every host.
@@ -188,22 +188,22 @@ anything.
188
 
189
  ## Budgets and Limits
190
 
191
- | Limit | Value | Where |
192
- | --- | --- | --- |
193
- | Pages read per search | 6 | `searchResultsToConsider`, mirrored by `MAX_URLS` in the endpoint |
194
- | Per-page deadline, redirects included | 6 s | `REQUEST_TIMEOUT_MS` |
195
- | Redirects followed | 3, each re-validated | `MAX_REDIRECTS` |
196
- | Refusals before a host is skipped | 3 in a row | `failureThreshold` in `server/pageReadHostBreaker.ts` |
197
- | How long a skipped host stays skipped | 5 minutes, then one probe read | `resetTimeout` in `server/pageReadHostBreaker.ts` |
198
- | Body read per page | 1.5 MB | `MAX_RESPONSE_BYTES` |
199
- | Excerpt cap per URL | 6,000 characters | `MAX_PAGE_CHARS`, `selectPassagesAcrossPages` |
200
- | Global excerpt budget | `MAX_PAGE_CHARS ร— pages` | `fetchPageContents` |
201
- | Near-duplicate threshold | 0.9 Jaccard | `JACCARD_THRESHOLD` |
202
- | Minimum usable text | 200 characters | `MIN_USEFUL_CHARS` |
203
- | Passage size | 180-1,200 characters | `MIN_PASSAGE_CHARS`, `MAX_PASSAGE_CHARS` |
204
- | Excerpt share of the context | 35% | `pageContentTokenBudgetRatio` |
205
- | Context assumed | `openAiContextLength` on the OpenAI-compatible backend, 4,096 elsewhere | `getPageContentTokenBudget` |
206
- | Whole-request timeout (client) | 20 s | `REQUEST_TIMEOUT` in `client/modules/pageContent.ts` |
207
 
208
  The token budget is shared across pages, served shortest-first: a page that
209
  needs less than its share leaves the rest to the others, and only pages that
@@ -327,21 +327,21 @@ under `pageReads` on `/status` and reset by a restart. Every one of them is
327
  attached to a constant in the table above, so the question they answer is "is
328
  this limit set where it should be", not "who read what":
329
 
330
- | Counter | The question it settles |
331
- | --- | --- |
332
- | `readRate` | Is the feature worth its latency at all |
333
- | `averageReadMs` | Is a 6 s deadline generous or stingy |
334
- | `skipped.timedOut` | How much is being lost to that deadline |
335
- | `skipped.tooLittleText` | Is the 200-character floor discarding real pages, or is the extractor missing their text |
336
- | `skipped.notADocument` | Are useful content types being turned away |
337
- | `skipped.blocked` | Are callers aiming at private space, deliberately or otherwise |
338
- | `skipped.httpForbidden` | How often bot walls or rate limits turn the instance away (401, 403, 429) |
339
- | `skipped.httpNotFound` | How often results point to dead links (404, 410) |
340
- | `skipped.httpOtherError` | How often hosts return other non-ok HTTP statuses |
341
- | `skipped.skippedByBreaker` | How many reads the host breaker saved, which is what its five-minute window is worth |
342
- | `circuitOpens` | How often a host was boxed, across every host: thirty skips can be one host boxed for a window or thirty hosts boxed once, and those move the threshold in different directions |
343
- | `bodiesTruncated` | Is the 1.5 MB body cap biting |
344
- | `excerptKeptRate` | How much of a page the per-URL cap and dedup keep |
345
 
346
  A counter can only be read in aggregate, so a single search cannot be recovered
347
  from it. The weakest point is an instance nobody else is using, where one search
@@ -353,21 +353,21 @@ failed, never which pages or what was asked.
353
  Every failure degrades to the snippet-only prompt, which is what the answer was
354
  grounded on before:
355
 
356
- | Failure | Result |
357
- | --- | --- |
358
- | Host resolves privately, or the scheme is not HTTP | That page is skipped, no request is made (`blocked`) |
359
- | Page times out or redirects fail | That page is skipped (`timedOut`, `redirectLimit`, `failed`) |
360
- | Page is not an HTML/PDF document | That page is skipped (`notADocument`) |
361
- | Bot wall, rate limit, or auth required (401, 403, 429) | That page is skipped (`httpForbidden`) |
362
- | Dead link (404, 410) | That page is skipped (`httpNotFound`) |
363
- | Other HTTP error (5xx, other non-ok status) | That page is skipped (`httpOtherError`) |
364
- | Host refused the last three reads | That page is skipped without a request for five minutes, then one read probes the host (`skippedByBreaker`) |
365
- | Page yields less than 200 characters | That page is skipped (`tooLittleText`) |
366
- | `/page-content` fails or times out | Answer falls back to snippets |
367
- | Setting turned off, or AI responses off | No page is ever read |
368
- | A newer search starts while pages are still being read | The late result is dropped instead of grounding the new answer |
369
- | Every passage on a page duplicates passages that ranked higher in the pooled order | That page keeps its snippet-only line; no empty excerpt block is emitted |
370
- | | The pooled ranking uses a per-passage position prior, so a later result can keep a paragraph the higher-ranked one carries further down its page. |
371
 
372
  ## Related Topics
373
 
 
10
 
11
  One switch controls the feature, and it is on by default:
12
 
13
+ | Switch | Who sets it | What it does |
14
+ | --------------------- | --------------------------- | ------------------------------------------------------- |
15
  | **Read Page Content** | The user, under AI Settings | Whether this browser reads the pages behind its results |
16
 
17
  The endpoint is always available; the user toggle decides whether each browser
 
145
  The interface is in English; the searches are not. Ranking therefore avoids
146
  anything written for one language:
147
 
148
+ | Concern | How it is handled | Why not the obvious thing |
149
+ | ------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
150
+ | Word boundaries | `Intl.Segmenter`, word granularity | `[^\p{L}\p{N}]+` cut Brahmic scripts and Thai at every combining vowel mark and dropped the marks, and it finds no boundary at all in Chinese, Japanese or Thai, which write none: a whole sentence arrived as one token that matched nothing, scoring fell through to document order, and the page's own navigation won |
151
+ | Sentence boundaries | `Intl.Segmenter`, sentence granularity | `[.!?]` never matches `ใ€‚`, `๏ผ`, `๏ผŸ` or `เฅค`, so long CJK and Indic blocks were cut at an arbitrary character mid-sentence |
152
+ | Uninformative words | Inverse document frequency over the pooled passages of all pages read for the search | A stop-word list only covers the language it was written in. A term found in most passages of a pool cannot say which passage to quote, in any language, and this needs no word list and no language detection |
153
+ | Inflection | A query term and a page word match when one is a prefix of the other, from 3 characters and within 3 | Stripping `-ing`, `-es`, `-s` is English morphology applied to every language: it turned the Portuguese `mรฃes` into `mรฃ` and never matched `mรฃe` |
154
 
155
  Both segmenters are script-driven, so the locale is left unset and the result
156
  is the same on every host.
 
188
 
189
  ## Budgets and Limits
190
 
191
+ | Limit | Value | Where |
192
+ | ------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------- |
193
+ | Pages read per search | 6 | `searchResultsToConsider`, mirrored by `MAX_URLS` in the endpoint |
194
+ | Per-page deadline, redirects included | 6 s | `REQUEST_TIMEOUT_MS` |
195
+ | Redirects followed | 3, each re-validated | `MAX_REDIRECTS` |
196
+ | Refusals before a host is skipped | 3 in a row | `failureThreshold` in `server/pageReadHostBreaker.ts` |
197
+ | How long a skipped host stays skipped | 5 minutes, then one probe read | `resetTimeout` in `server/pageReadHostBreaker.ts` |
198
+ | Body read per page | 1.5 MB | `MAX_RESPONSE_BYTES` |
199
+ | Excerpt cap per URL | 6,000 characters | `MAX_PAGE_CHARS`, `selectPassagesAcrossPages` |
200
+ | Global excerpt budget | `MAX_PAGE_CHARS ร— pages` | `fetchPageContents` |
201
+ | Near-duplicate threshold | 0.9 Jaccard | `JACCARD_THRESHOLD` |
202
+ | Minimum usable text | 200 characters | `MIN_USEFUL_CHARS` |
203
+ | Passage size | 180-1,200 characters | `MIN_PASSAGE_CHARS`, `MAX_PASSAGE_CHARS` |
204
+ | Excerpt share of the context | 35% | `pageContentTokenBudgetRatio` |
205
+ | Context assumed | `openAiContextLength` on the OpenAI-compatible backend, 4,096 elsewhere | `getPageContentTokenBudget` |
206
+ | Whole-request timeout (client) | 20 s | `REQUEST_TIMEOUT` in `client/modules/pageContent.ts` |
207
 
208
  The token budget is shared across pages, served shortest-first: a page that
209
  needs less than its share leaves the rest to the others, and only pages that
 
327
  attached to a constant in the table above, so the question they answer is "is
328
  this limit set where it should be", not "who read what":
329
 
330
+ | Counter | The question it settles |
331
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
332
+ | `readRate` | Is the feature worth its latency at all |
333
+ | `averageReadMs` | Is a 6 s deadline generous or stingy |
334
+ | `skipped.timedOut` | How much is being lost to that deadline |
335
+ | `skipped.tooLittleText` | Is the 200-character floor discarding real pages, or is the extractor missing their text |
336
+ | `skipped.notADocument` | Are useful content types being turned away |
337
+ | `skipped.blocked` | Are callers aiming at private space, deliberately or otherwise |
338
+ | `skipped.httpForbidden` | How often bot walls or rate limits turn the instance away (401, 403, 429) |
339
+ | `skipped.httpNotFound` | How often results point to dead links (404, 410) |
340
+ | `skipped.httpOtherError` | How often hosts return other non-ok HTTP statuses |
341
+ | `skipped.skippedByBreaker` | How many reads the host breaker saved, which is what its five-minute window is worth |
342
+ | `circuitOpens` | How often a host was boxed, across every host: thirty skips can be one host boxed for a window or thirty hosts boxed once, and those move the threshold in different directions |
343
+ | `bodiesTruncated` | Is the 1.5 MB body cap biting |
344
+ | `excerptKeptRate` | How much of a page the per-URL cap and dedup keep |
345
 
346
  A counter can only be read in aggregate, so a single search cannot be recovered
347
  from it. The weakest point is an instance nobody else is using, where one search
 
353
  Every failure degrades to the snippet-only prompt, which is what the answer was
354
  grounded on before:
355
 
356
+ | Failure | Result |
357
+ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
358
+ | Host resolves privately, or the scheme is not HTTP | That page is skipped, no request is made (`blocked`) |
359
+ | Page times out or redirects fail | That page is skipped (`timedOut`, `redirectLimit`, `failed`) |
360
+ | Page is not an HTML/PDF document | That page is skipped (`notADocument`) |
361
+ | Bot wall, rate limit, or auth required (401, 403, 429) | That page is skipped (`httpForbidden`) |
362
+ | Dead link (404, 410) | That page is skipped (`httpNotFound`) |
363
+ | Other HTTP error (5xx, other non-ok status) | That page is skipped (`httpOtherError`) |
364
+ | Host refused the last three reads | That page is skipped without a request for five minutes, then one read probes the host (`skippedByBreaker`) |
365
+ | Page yields less than 200 characters | That page is skipped (`tooLittleText`) |
366
+ | `/page-content` fails or times out | Answer falls back to snippets |
367
+ | Setting turned off, or AI responses off | No page is ever read |
368
+ | A newer search starts while pages are still being read | The late result is dropped instead of grounding the new answer |
369
+ | Every passage on a page duplicates passages that ranked higher in the pooled order | That page keeps its snippet-only line; no empty excerpt block is emitted |
370
+ | | The pooled ranking uses a per-passage position prior, so a later result can keep a paragraph the higher-ranked one carries further down its page. |
371
 
372
  ## Related Topics
373
 
docs/performance-profile-2571.md CHANGED
@@ -22,10 +22,10 @@ once the full answer has rendered.
22
 
23
  ## Result
24
 
25
- | Build | Long tasks in the streaming window | Total main-thread time blocked | Longest single task |
26
- | --- | --- | --- | --- |
27
- | `main` (before) | 30, 31, 31, 32, 32 | 2008, 2050, 2143, 2149, 2207 ms | 108โ€“186 ms |
28
- | All seven fixes merged (after) | 0, 0, 1, 1, 1 | 0, 0, 51, 52, 52 ms | โ‰ค 52 ms |
29
 
30
  Roughly **2.1 seconds of blocking main-thread work per streamed answer is gone**, and the worst
31
  single task drops from ~160โ€“190 ms to at or below the 50 ms long-task reporting threshold โ€” i.e.
@@ -33,13 +33,13 @@ after the change there is effectively nothing left to report.
33
 
34
  ## What removed what
35
 
36
- | Long task on `main` | Removed by |
37
- | --- | --- |
38
- | Full-markdown re-parse on every throttled frame (12 Hz), growing with the answer | `perf/memoized-markdown-blocks` โ€” top-level blocks are memoized, so a frame that only extends the last block re-renders that block |
39
- | Shiki loading from `shiki/bundle/full` for the first code block | `perf/shiki-lazy-language-subset` โ€” web subset, and a language outside it degrades without a load attempt |
40
  | One `resize` listener per citation link, each reading `scrollWidth` and calling `setState` | `perf/shared-resize-observer-citation-links` โ€” one shared `ResizeObserver`, and the expand animates `transform`/`opacity` instead of `width` |
41
- | `gptTokenizer.encode()` over every full page body at the search-to-generation handoff | `perf/page-excerpt-tokenization-worker` โ€” the tokenization runs in a worker, with a synchronous fallback |
42
- | Re-encoding the whole joined candidate per iteration in the rolling summary | `perf/incremental-rolling-summary-tokens` โ€” each part encoded once, running count kept |
43
 
44
  ## Reproducing
45
 
 
22
 
23
  ## Result
24
 
25
+ | Build | Long tasks in the streaming window | Total main-thread time blocked | Longest single task |
26
+ | ------------------------------ | ---------------------------------- | ------------------------------- | ------------------- |
27
+ | `main` (before) | 30, 31, 31, 32, 32 | 2008, 2050, 2143, 2149, 2207 ms | 108โ€“186 ms |
28
+ | All seven fixes merged (after) | 0, 0, 1, 1, 1 | 0, 0, 51, 52, 52 ms | โ‰ค 52 ms |
29
 
30
  Roughly **2.1 seconds of blocking main-thread work per streamed answer is gone**, and the worst
31
  single task drops from ~160โ€“190 ms to at or below the 50 ms long-task reporting threshold โ€” i.e.
 
33
 
34
  ## What removed what
35
 
36
+ | Long task on `main` | Removed by |
37
+ | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
38
+ | Full-markdown re-parse on every throttled frame (12 Hz), growing with the answer | `perf/memoized-markdown-blocks` โ€” top-level blocks are memoized, so a frame that only extends the last block re-renders that block |
39
+ | Shiki loading from `shiki/bundle/full` for the first code block | `perf/shiki-lazy-language-subset` โ€” web subset, and a language outside it degrades without a load attempt |
40
  | One `resize` listener per citation link, each reading `scrollWidth` and calling `setState` | `perf/shared-resize-observer-citation-links` โ€” one shared `ResizeObserver`, and the expand animates `transform`/`opacity` instead of `width` |
41
+ | `gptTokenizer.encode()` over every full page body at the search-to-generation handoff | `perf/page-excerpt-tokenization-worker` โ€” the tokenization runs in a worker, with a synchronous fallback |
42
+ | Re-encoding the whole joined candidate per iteration in the rolling summary | `perf/incremental-rolling-summary-tokens` โ€” each part encoded once, running count kept |
43
 
44
  ## Reproducing
45
 
docs/project-structure.md CHANGED
@@ -10,11 +10,10 @@
10
  - **`Logs/`**: Application logging and debug modal components
11
  - **`Pages/`**: Route-level page components
12
  - **`Main/`**: Main search interface with menu and settings
13
- - **`Search/`**: Search functionality components
14
- - **`Form/`**: Search input and form components
15
- - **`History/`**: Search history and saved searches
16
- - **`Results/`**: Search results display (textual and graphical)
17
- - **`Settings/`**: Application settings and configuration
18
  - **`hooks/`**: Custom React hooks
19
  - **`modules/`**: Core business logic (PubSub stores, search services, database layers)
20
  - **`public/`**: Static assets
@@ -37,37 +36,37 @@ This self-contained structure makes it easy for agents to understand and modify
37
 
38
  The `client/modules/` directory contains core business logic organized by domain:
39
 
40
- | Module | Purpose |
41
- |--------|---------|
42
- | `pubSub.ts` | All PubSub channels for state management |
43
- | `search.ts` | Search orchestration, caching, and reranking |
44
- | `textGeneration.ts` | AI response generation and chat handling |
45
- | `textGenerationWith*.ts` | Provider-specific inference implementations (Wllama, OpenAI, Horde, Internal API) |
46
- | `history.ts` | IndexedDB persistence for searches and chat |
47
- | `settings.ts` | Default settings and inference type definitions |
48
- | `followUpQuestions.ts` | Generates contextual follow-up questions |
49
- | `relatedSearchQuery.ts` | Creates related search queries from conversation |
50
- | `querySuggestions.ts` | Query suggestion UI data management |
51
- | `webGpu.ts` | WebGPU availability detection |
52
- | `wllama.ts` | Wllama model configuration and management |
53
- | `accessKey.ts` | Access key validation and storage |
54
- | `searchTokenHash.ts` | CSRF protection token generation |
55
- | `parentWindow.ts` | PostMessage API for parent window embedding |
56
- | `logEntries.ts` | Application logging with unique IDs |
57
- | `appInfo.ts` | Application metadata and version info |
58
- | `textToSpeech.ts` | Reads answers aloud: engine selection, fallback, and the playback queue |
59
- | `textToSpeechWorker.ts` | Synthesizes sentences off the main thread |
60
- | `textToSpeechWorkerProtocol.ts` | Message types shared by both sides of the worker |
61
- | `piper.ts` | The only module that imports `@diffusionstudio/vits-web` |
62
- | `speechToText.ts` | Dictation: microphone capture, the worker's lifecycle, and engine selection between the on-device model and `SpeechRecognition` |
63
- | `speechToTextWorker.ts` | Runs the Moonshine transcriber off the main thread |
64
- | `speechToTextWorkerProtocol.ts` | Message types shared by both sides of the dictation worker |
65
- | `moonshine.ts` | The only module that imports `@moonshine-ai/moonshine-wasm` |
66
- | `keyboard.ts` | Keyboard shortcut handling |
67
- | `pageContent.ts` | Requests extracted result-page text used to ground answers |
68
- | `systemPrompt.ts` | System prompt templates |
69
- | `stringFormatters.ts` | Text formatting utilities |
70
- | `types.ts` | Shared TypeScript type definitions |
71
 
72
  ## Related Topics
73
 
 
10
  - **`Logs/`**: Application logging and debug modal components
11
  - **`Pages/`**: Route-level page components
12
  - **`Main/`**: Main search interface with menu and settings
13
+ - **`Search/`**: Search functionality components
14
+ - **`Form/`**: Search input and form components
15
+ - **`History/`**: Search history and saved searches
16
+ - **`Results/`**: Search results display (textual and graphical) - **`Settings/`**: Application settings and configuration
 
17
  - **`hooks/`**: Custom React hooks
18
  - **`modules/`**: Core business logic (PubSub stores, search services, database layers)
19
  - **`public/`**: Static assets
 
36
 
37
  The `client/modules/` directory contains core business logic organized by domain:
38
 
39
+ | Module | Purpose |
40
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
41
+ | `pubSub.ts` | All PubSub channels for state management |
42
+ | `search.ts` | Search orchestration, caching, and reranking |
43
+ | `textGeneration.ts` | AI response generation and chat handling |
44
+ | `textGenerationWith*.ts` | Provider-specific inference implementations (Wllama, OpenAI, Horde, Internal API) |
45
+ | `history.ts` | IndexedDB persistence for searches and chat |
46
+ | `settings.ts` | Default settings and inference type definitions |
47
+ | `followUpQuestions.ts` | Generates contextual follow-up questions |
48
+ | `relatedSearchQuery.ts` | Creates related search queries from conversation |
49
+ | `querySuggestions.ts` | Query suggestion UI data management |
50
+ | `webGpu.ts` | WebGPU availability detection |
51
+ | `wllama.ts` | Wllama model configuration and management |
52
+ | `accessKey.ts` | Access key validation and storage |
53
+ | `searchTokenHash.ts` | CSRF protection token generation |
54
+ | `parentWindow.ts` | PostMessage API for parent window embedding |
55
+ | `logEntries.ts` | Application logging with unique IDs |
56
+ | `appInfo.ts` | Application metadata and version info |
57
+ | `textToSpeech.ts` | Reads answers aloud: engine selection, fallback, and the playback queue |
58
+ | `textToSpeechWorker.ts` | Synthesizes sentences off the main thread |
59
+ | `textToSpeechWorkerProtocol.ts` | Message types shared by both sides of the worker |
60
+ | `piper.ts` | The only module that imports `@diffusionstudio/vits-web` |
61
+ | `speechToText.ts` | Dictation: microphone capture, the worker's lifecycle, and engine selection between the on-device model and `SpeechRecognition` |
62
+ | `speechToTextWorker.ts` | Runs the Moonshine transcriber off the main thread |
63
+ | `speechToTextWorkerProtocol.ts` | Message types shared by both sides of the dictation worker |
64
+ | `moonshine.ts` | The only module that imports `@moonshine-ai/moonshine-wasm` |
65
+ | `keyboard.ts` | Keyboard shortcut handling |
66
+ | `pageContent.ts` | Requests extracted result-page text used to ground answers |
67
+ | `systemPrompt.ts` | System prompt templates |
68
+ | `stringFormatters.ts` | Text formatting utilities |
69
+ | `types.ts` | Shared TypeScript type definitions |
70
 
71
  ## Related Topics
72
 
docs/pull-requests.md CHANGED
@@ -40,14 +40,12 @@ This comprehensive check includes:
40
  The GitHub Release body is built from `changelog.md`, not from GitHub's generated notes: the publish workflow takes every changelog line that was not there at the previous release tag, under its `## YYYY-MM-DD` heading. A change with no entry therefore ships with no mention in the Release.
41
 
42
  The `changelog-guard` job on every PR to `main` fails when the PR changes a user-facing path and does not touch `changelog.md`. The trigger paths are:
43
-
44
  - anything under `client/`, `server/`, or `shared/`
45
  - `Dockerfile`, `docker-compose.yml`, `docker-compose.production.yml`, `searxng-settings.yml`
46
 
47
  Add your entry to `changelog.md` under a `## YYYY-MM-DD` heading for today's date in UTC, most recent date first.
48
 
49
  Two ways out:
50
-
51
  - Add the `skip-changelog` label when the change touches a trigger path but is not notable for users - an internal refactor, a rename, a CI-only edit inside a trigger path.
52
  - Renovate and Dependabot PRs pass automatically. Renovate bumps the pinned SearXNG commit in the `Dockerfile` and cannot write an entry.
53
 
 
40
  The GitHub Release body is built from `changelog.md`, not from GitHub's generated notes: the publish workflow takes every changelog line that was not there at the previous release tag, under its `## YYYY-MM-DD` heading. A change with no entry therefore ships with no mention in the Release.
41
 
42
  The `changelog-guard` job on every PR to `main` fails when the PR changes a user-facing path and does not touch `changelog.md`. The trigger paths are:
 
43
  - anything under `client/`, `server/`, or `shared/`
44
  - `Dockerfile`, `docker-compose.yml`, `docker-compose.production.yml`, `searxng-settings.yml`
45
 
46
  Add your entry to `changelog.md` under a `## YYYY-MM-DD` heading for today's date in UTC, most recent date first.
47
 
48
  Two ways out:
 
49
  - Add the `skip-changelog` label when the change touches a trigger path but is not notable for users - an internal refactor, a rename, a CI-only edit inside a trigger path.
50
  - Renovate and Dependabot PRs pass automatically. Renovate bumps the pinned SearXNG commit in the `Dockerfile` and cannot write an entry.
51
 
docs/quick-start.md CHANGED
@@ -59,11 +59,11 @@ Development mode overrides the container command to run `npm install && npm run
59
 
60
  ### Port Configuration
61
 
62
- | Port | Service | Variable | Notes |
63
- |------|---------|----------|-------|
64
- | 7860 | Node.js (Vite) | `PORT` | Main application (both dev and production) |
65
- | 7861 | HMR | `HMR_PORT` | Vite HMR server (development only) |
66
- | 8888 | SearXNG | - | Internal metasearch engine (not exposed) |
67
 
68
  ## First Configuration
69
 
@@ -117,16 +117,19 @@ Access at `http://localhost:7860`
117
  ## Verification
118
 
119
  ### Test Search
 
120
  1. Enter any query in the search box
121
  2. Press Enter or click Search
122
  3. Results should appear within 2-5 seconds
123
 
124
  ### Test AI Response
 
125
  1. Toggle "Enable AI Response" in Settings
126
  2. Search for "What is quantum computing?"
127
  3. After search results load, an AI-generated response should appear with citations
128
 
129
  ### Test Chat
 
130
  1. After getting an AI response
131
  2. Type a follow-up question like "Tell me more"
132
  3. The AI should respond using conversation context
@@ -134,12 +137,14 @@ Access at `http://localhost:7860`
134
  ## Common Issues
135
 
136
  ### Issue: Search returns no results
 
137
  **Solution**: Verify SearXNG is running. Check container logs:
138
  ```bash
139
  docker compose logs searxng
140
  ```
141
 
142
  ### Issue: AI response never loads
 
143
  **Solution**: Check browser console for errors. Common causes:
144
  - WebGPU not supported (app will automatically fall back to Wllama)
145
  - Model download blocked by firewall or CORS
@@ -148,13 +153,15 @@ docker compose logs searxng
148
  - Try switching inference types in Settings
149
 
150
  ### Issue: Access key not working
 
151
  **Solution**: Ensure `ACCESS_KEYS` is set in `.env` file and containers were rebuilt with `--build` flag.
152
 
153
  ### Issue: Port already in use
 
154
  **Solution**: Change ports in `docker-compose.yml`:
155
  ```yaml
156
  ports:
157
- - "7862:7861" # Use 7862 instead of 7860 (HMR port)
158
  ```
159
 
160
  ## Next Steps
 
59
 
60
  ### Port Configuration
61
 
62
+ | Port | Service | Variable | Notes |
63
+ | ---- | -------------- | ---------- | ------------------------------------------ |
64
+ | 7860 | Node.js (Vite) | `PORT` | Main application (both dev and production) |
65
+ | 7861 | HMR | `HMR_PORT` | Vite HMR server (development only) |
66
+ | 8888 | SearXNG | - | Internal metasearch engine (not exposed) |
67
 
68
  ## First Configuration
69
 
 
117
  ## Verification
118
 
119
  ### Test Search
120
+
121
  1. Enter any query in the search box
122
  2. Press Enter or click Search
123
  3. Results should appear within 2-5 seconds
124
 
125
  ### Test AI Response
126
+
127
  1. Toggle "Enable AI Response" in Settings
128
  2. Search for "What is quantum computing?"
129
  3. After search results load, an AI-generated response should appear with citations
130
 
131
  ### Test Chat
132
+
133
  1. After getting an AI response
134
  2. Type a follow-up question like "Tell me more"
135
  3. The AI should respond using conversation context
 
137
  ## Common Issues
138
 
139
  ### Issue: Search returns no results
140
+
141
  **Solution**: Verify SearXNG is running. Check container logs:
142
  ```bash
143
  docker compose logs searxng
144
  ```
145
 
146
  ### Issue: AI response never loads
147
+
148
  **Solution**: Check browser console for errors. Common causes:
149
  - WebGPU not supported (app will automatically fall back to Wllama)
150
  - Model download blocked by firewall or CORS
 
153
  - Try switching inference types in Settings
154
 
155
  ### Issue: Access key not working
156
+
157
  **Solution**: Ensure `ACCESS_KEYS` is set in `.env` file and containers were rebuilt with `--build` flag.
158
 
159
  ### Issue: Port already in use
160
+
161
  **Solution**: Change ports in `docker-compose.yml`:
162
  ```yaml
163
  ports:
164
+ - "7862:7861" # Use 7862 instead of 7860 (HMR port)
165
  ```
166
 
167
  ## Next Steps
docs/reranking.md CHANGED
@@ -6,19 +6,18 @@ MiniSearch optionally reranks search results using a cross-encoder model running
6
 
7
  The reranking subsystem consists of four components:
8
 
9
- | Component | File | Responsibility |
10
- |-----------|------|----------------|
11
- | Service Manager | `server/rerankerService.ts` | Readiness state, reranking inference |
12
- | Model Loading | `server/utils/onnxModelLoader.ts` | Shared model download, tokenizer loading, and session creation (used by both the reranker and the bi-encoder) |
13
- | Ranking Logic | `server/rankSearchResults.ts` | Score-based filtering and result reordering |
14
- | Server Hook | `server/rerankerServiceHook.ts` | Startup/shutdown coordination with Vite server |
15
 
16
  ## Service Lifecycle
17
 
18
  ### Startup
19
 
20
  The `rerankerServiceHook` starts the reranker during server initialization:
21
-
22
  1. Downloads the model and tokenizer from HuggingFace if not present (`cross-encoder/mmarco-mMiniLMv2-L12-H384-v1`)
23
  2. Creates an ONNX Runtime inference session
24
  3. Performs a warmup inference (`query: "test"`, `documents: ["test document"]`) to ensure the graph is initialized
@@ -40,12 +39,12 @@ The session is created with `["cpu"]`, with no configuration to set, and there i
40
 
41
  The reranker ships as a dynamically quantized graph, and the WebGPU provider has no kernels for its integer matmuls. It registers happily and then hands every one of them back to the CPU, paying a round trip each time: 812ms against 172ms of the same work, with scores drifting by as much as 1.15 and reordering results. The bi-encoder is fp32 and was not benchmarked on accelerators, but inherits the CPU-only choice.
42
 
43
- | Provider | Availability in the Node binding | Notes |
44
- |----------|----------------------------------|-------|
45
- | `cpu` | Everywhere | The only provider used |
46
- | `webgpu` | Windows, Linux x64, macOS | Not used: no integer-matmul kernels, so a quantized graph runs ~4.7x slower than on CPU and its scores drift |
47
- | `cuda` | Linux x64 (CUDA v12) | Not used: the binaries are not bundled, and would need `npm install onnxruntime-node --onnxruntime-node-install=cuda12` |
48
- | `coreml` | macOS | Not used: slower than CPU for this model's dynamic shapes |
49
 
50
  ### One Document per Call
51
 
@@ -80,7 +79,6 @@ Documents are sent to the reranker whole. Truncation happens by tokens inside th
80
  ### Scoring and Filtering
81
 
82
  The reranker returns the classifier's raw relevance logit for each document. Scores are deliberately not passed through a sigmoid, because the filter below is calibrated against the raw scale. Results are then filtered using a two-stage statistical approach:
83
-
84
  1. **Score Normalization**: Scores are shifted to positive range by adding the absolute value of the minimum score
85
  2. **Standard Deviation Filter**: Results below `mean - kStandardDeviationFactor * standardDeviation` are filtered out
86
  - `kStandardDeviationFactor = 0.3`
@@ -92,7 +90,6 @@ This filter is invariant to linear rescaling of the scores, since both the score
92
  ### Preserve Top Results Mode
93
 
94
  When `preserveTopResults = true`, the ranking algorithm:
95
-
96
  1. Keeps the original top result (first from SearXNG) at position 1
97
  2. Filters remaining results by score
98
  3. Takes up to 9 next-best results, sorted by reranker score
@@ -116,15 +113,15 @@ Reranking is applied to both text and image search results. For image results, t
116
 
117
  ## Model Details
118
 
119
- | Property | Value |
120
- |----------|-------|
121
- | Model | mmarco-mMiniLMv2-L12-H384-v1 |
122
- | Format | ONNX, dynamically quantized (`onnx/model_quint8_avx2.onnx`, 119MB) |
123
- | HuggingFace Repo | cross-encoder/mmarco-mMiniLMv2-L12-H384-v1 |
124
- | Type | Cross-encoder reranker (XLM-RoBERTa, one relevance logit) |
125
- | Size | 12 layers, 118M parameters, of which 96M are the 250k-token vocabulary |
126
- | Max sequence length | 512 tokens, the model's own limit, enforced via `MAX_SEQUENCE_LENGTH` |
127
- | Storage | `server/models/cross-encoder/mmarco-mMiniLMv2-L12-H384-v1/` |
128
 
129
  It is trained on mMARCO, the machine-translated multilingual MS MARCO, and is genuinely multilingual: a 250k-token sentencepiece vocabulary shared across languages, which is also where most of the parameter count goes.
130
 
@@ -150,15 +147,15 @@ npx vitest run --config vitest.integration.config.ts
150
 
151
  ## Error Handling
152
 
153
- | Scenario | Behavior |
154
- |----------|----------|
155
- | Reranker not ready | Falls back to unranked SearXNG results |
156
- | Model fails to load | Logged by the hook; reranker stays unready and search returns unranked results |
157
- | Cached file of the wrong size | Logged, then downloaded again before the session is created |
158
- | Download shorter than the reported size | Throws before anything is written, so the cache keeps no partial file |
159
- | HuggingFace unreachable with a warm cache | Metadata failure is logged and the cached files are used unchanged |
160
- | Empty documents array | Returns empty array without running inference |
161
- | Unicode sanitization needed | Logs warning, continues with sanitized input |
162
 
163
  ## Observability
164
 
@@ -184,8 +181,20 @@ taking 20 ms produce:
184
  "skippedUnhealthy": 0,
185
  "failed": 0,
186
  "byType": {
187
- "text": { "reranks": 2, "averageMs": 200, "considered": 20, "kept": 5, "keptRate": 25 },
188
- "images": { "reranks": 1, "averageMs": 20, "considered": 2, "kept": 2, "keptRate": 100 }
 
 
 
 
 
 
 
 
 
 
 
 
189
  }
190
  }
191
  }
 
6
 
7
  The reranking subsystem consists of four components:
8
 
9
+ | Component | File | Responsibility |
10
+ | --------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------- |
11
+ | Service Manager | `server/rerankerService.ts` | Readiness state, reranking inference |
12
+ | Model Loading | `server/utils/onnxModelLoader.ts` | Shared model download, tokenizer loading, and session creation (used by both the reranker and the bi-encoder) |
13
+ | Ranking Logic | `server/rankSearchResults.ts` | Score-based filtering and result reordering |
14
+ | Server Hook | `server/rerankerServiceHook.ts` | Startup/shutdown coordination with Vite server |
15
 
16
  ## Service Lifecycle
17
 
18
  ### Startup
19
 
20
  The `rerankerServiceHook` starts the reranker during server initialization:
 
21
  1. Downloads the model and tokenizer from HuggingFace if not present (`cross-encoder/mmarco-mMiniLMv2-L12-H384-v1`)
22
  2. Creates an ONNX Runtime inference session
23
  3. Performs a warmup inference (`query: "test"`, `documents: ["test document"]`) to ensure the graph is initialized
 
39
 
40
  The reranker ships as a dynamically quantized graph, and the WebGPU provider has no kernels for its integer matmuls. It registers happily and then hands every one of them back to the CPU, paying a round trip each time: 812ms against 172ms of the same work, with scores drifting by as much as 1.15 and reordering results. The bi-encoder is fp32 and was not benchmarked on accelerators, but inherits the CPU-only choice.
41
 
42
+ | Provider | Availability in the Node binding | Notes |
43
+ | -------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
44
+ | `cpu` | Everywhere | The only provider used |
45
+ | `webgpu` | Windows, Linux x64, macOS | Not used: no integer-matmul kernels, so a quantized graph runs ~4.7x slower than on CPU and its scores drift |
46
+ | `cuda` | Linux x64 (CUDA v12) | Not used: the binaries are not bundled, and would need `npm install onnxruntime-node --onnxruntime-node-install=cuda12` |
47
+ | `coreml` | macOS | Not used: slower than CPU for this model's dynamic shapes |
48
 
49
  ### One Document per Call
50
 
 
79
  ### Scoring and Filtering
80
 
81
  The reranker returns the classifier's raw relevance logit for each document. Scores are deliberately not passed through a sigmoid, because the filter below is calibrated against the raw scale. Results are then filtered using a two-stage statistical approach:
 
82
  1. **Score Normalization**: Scores are shifted to positive range by adding the absolute value of the minimum score
83
  2. **Standard Deviation Filter**: Results below `mean - kStandardDeviationFactor * standardDeviation` are filtered out
84
  - `kStandardDeviationFactor = 0.3`
 
90
  ### Preserve Top Results Mode
91
 
92
  When `preserveTopResults = true`, the ranking algorithm:
 
93
  1. Keeps the original top result (first from SearXNG) at position 1
94
  2. Filters remaining results by score
95
  3. Takes up to 9 next-best results, sorted by reranker score
 
113
 
114
  ## Model Details
115
 
116
+ | Property | Value |
117
+ | ------------------- | ---------------------------------------------------------------------- |
118
+ | Model | mmarco-mMiniLMv2-L12-H384-v1 |
119
+ | Format | ONNX, dynamically quantized (`onnx/model_quint8_avx2.onnx`, 119MB) |
120
+ | HuggingFace Repo | cross-encoder/mmarco-mMiniLMv2-L12-H384-v1 |
121
+ | Type | Cross-encoder reranker (XLM-RoBERTa, one relevance logit) |
122
+ | Size | 12 layers, 118M parameters, of which 96M are the 250k-token vocabulary |
123
+ | Max sequence length | 512 tokens, the model's own limit, enforced via `MAX_SEQUENCE_LENGTH` |
124
+ | Storage | `server/models/cross-encoder/mmarco-mMiniLMv2-L12-H384-v1/` |
125
 
126
  It is trained on mMARCO, the machine-translated multilingual MS MARCO, and is genuinely multilingual: a 250k-token sentencepiece vocabulary shared across languages, which is also where most of the parameter count goes.
127
 
 
147
 
148
  ## Error Handling
149
 
150
+ | Scenario | Behavior |
151
+ | ----------------------------------------- | ------------------------------------------------------------------------------ |
152
+ | Reranker not ready | Falls back to unranked SearXNG results |
153
+ | Model fails to load | Logged by the hook; reranker stays unready and search returns unranked results |
154
+ | Cached file of the wrong size | Logged, then downloaded again before the session is created |
155
+ | Download shorter than the reported size | Throws before anything is written, so the cache keeps no partial file |
156
+ | HuggingFace unreachable with a warm cache | Metadata failure is logged and the cached files are used unchanged |
157
+ | Empty documents array | Returns empty array without running inference |
158
+ | Unicode sanitization needed | Logs warning, continues with sanitized input |
159
 
160
  ## Observability
161
 
 
181
  "skippedUnhealthy": 0,
182
  "failed": 0,
183
  "byType": {
184
+ "text": {
185
+ "reranks": 2,
186
+ "averageMs": 200,
187
+ "considered": 20,
188
+ "kept": 5,
189
+ "keptRate": 25
190
+ },
191
+ "images": {
192
+ "reranks": 1,
193
+ "averageMs": 20,
194
+ "considered": 2,
195
+ "kept": 2,
196
+ "keptRate": 100
197
+ }
198
  }
199
  }
200
  }
docs/search-history.md CHANGED
@@ -1,11 +1,12 @@
1
  # Search History System
2
 
3
  ## Overview
 
4
  MiniSearch keeps a local-first record of every search so agents can resurface text results, image results, and generated responses without hitting remote services again. All persistence lives inside the browser via Dexie/IndexedDB and is never synced to external servers, preserving the privacy posture of the app.
5
 
6
  ## Data Model and Storage
7
- The `HistoryDatabase` Dexie instance stores three related tables:
8
 
 
9
  1. `searches` โ€“ canonical log of each query plus hydrated results payloads (text, image, run metadata, source, pin state).
10
  2. `llmResponses` โ€“ snapshot of every AI answer tied to its originating search run, enabling restoration of the assistant output.
11
  3. `chatHistory` โ€“ chronological record of chat turns (user/assistant roles) scoped by `conversationId` (the `searchRunId`).
@@ -13,8 +14,8 @@ The `HistoryDatabase` Dexie instance stores three related tables:
13
  All three tables share a `searchRunId` foreign key so restoring a specific run can bring back the search results, saved AI response, and chat transcript atomically.
14
 
15
  ## Cleanup Guarantees
16
- To keep IndexedDB lean, inserts trigger `performCleanup()`, which enforces:
17
 
 
18
  - **Retention window** โ€“ non-pinned entries older than `historyRetentionDays` are deleted in batches of 100.
19
  - **Max entries** โ€“ if the total exceeds `historyMaxEntries`, the oldest unpinned rows past the limit are removed.
20
  - **Pin protection** โ€“ pinned entries are skipped by both retention and max-entry sweeps.
@@ -22,8 +23,8 @@ To keep IndexedDB lean, inserts trigger `performCleanup()`, which enforces:
22
  Cleanup is gated by `historyAutoCleanup`, so advanced users can opt out. These knobs come from the settings store and can be updated at runtime.
23
 
24
  ## Hook API
25
- `useSearchHistory` exposes the canonical API surface for components:
26
 
 
27
  - Fetches recent searches (optionally paginated) and exposes filtered/grouped views.
28
  - Provides fuzzy search, pin toggling, delete/clear operations, and error retry helpers.
29
  - Keeps derived state such as grouped sections (Today/Yesterday/etc.) in sync with the settings flag `historyGroupByDate`.
@@ -32,14 +33,17 @@ Cleanup is gated by `historyAutoCleanup`, so advanced users can opt out. These k
32
  Because the hook writes via Dexie directly, any component can call `addSearchToHistory` to persist new runs immediately after text or image searches resolve.
33
 
34
  ## UI Surfaces
 
35
  - **History Drawer** โ€“ wraps the hook data in a drawer with filtering, pin/delete affordances, grouped stacks, and an Analytics tab powered by `SearchStats` in compact mode. The drawer guards against disabled history settings and mirrors the hook filtered list in real time.
36
  - **History Button** โ€“ lazy-loads the drawer and logs open/close events for observability.
37
  - **History Restore** โ€“ the `useHistoryRestore` hook recreates full search state (query string, search results, AI response, and chat transcript) when a user replays an entry.
38
 
39
  ## Settings Surface
 
40
  `HistorySettings` wires Mantine form controls to the settings pub/sub so users can toggle history, adjust retention/max entries, and clear all rows (with confirmation + toast feedback). It relies on the same hook to know when the table is empty before enabling destructive actions.
41
 
42
  ## Analytics Integration
 
43
  The History Drawer Analytics tab renders the compact `SearchStats` cards scoped to `period="all"`, giving total searches, daily averages, and most active hours pulled from the same IndexedDB data. Keeping analytics co-located ensures the metrics share the hook filtering and cleanup guarantees.
44
 
45
  ## Search Cache System
@@ -47,19 +51,19 @@ The History Drawer Analytics tab renders the compact `SearchStats` cards scoped
47
  Alongside the history database, MiniSearch uses a separate `SearchCacheDatabase` for temporary result caching:
48
 
49
  **Schema:**
50
- | Store | Key | Index | Entry Type |
51
- |-------|-----|-------|------------|
52
- | `textSearchHistory` | hashed query | timestamp | `TextSearchCache` |
53
  | `imageSearchHistory` | hashed query | timestamp | `ImageSearchCache` |
54
 
55
  **Configuration:**
56
- | Parameter | Value | Description |
57
- |-----------|-------|-------------|
58
- | TTL | 15 minutes | Freshness window: a hit inside it skips the network |
59
- | MAX_STALE_RETENTION | 24 hours | How long an expired entry is kept, so a failed live search can serve it flagged stale |
60
- | MAX_ENTRIES | 100 | Maximum cached queries per store |
61
- | PRUNE_INTERVAL | 10 writes | Cache writes between LRU prune passes |
62
- | REQUEST_TIMEOUT | 30,000 ms | Search request timeout |
63
 
64
  **Performance Metrics:** The `cacheMetrics` object tracks hits/misses per search type and logs hit rates every 10 operations. Query hashing produces deterministic cache keys for reliable lookups.
65
 
@@ -67,12 +71,12 @@ Alongside the history database, MiniSearch uses a separate `SearchCacheDatabase`
67
 
68
  Lightweight state persisted via `createLocalStoragePubSub`:
69
 
70
- | Key | Type | Default | Purpose |
71
- |-----|------|---------|---------|
72
- | `settings` | `Settings` | `defaultSettings` | App configuration |
73
- | `querySuggestions` | `string[]` | `[]` | Search suggestion pool |
74
- | `lastSearchTokenHash` | `string` | `""` | Cached security token hash |
75
- | `menuExpandedAccordions` | `string[]` | `[]` | Menu UI state |
76
 
77
  ## Performance Measurement
78
 
 
1
  # Search History System
2
 
3
  ## Overview
4
+
5
  MiniSearch keeps a local-first record of every search so agents can resurface text results, image results, and generated responses without hitting remote services again. All persistence lives inside the browser via Dexie/IndexedDB and is never synced to external servers, preserving the privacy posture of the app.
6
 
7
  ## Data Model and Storage
 
8
 
9
+ The `HistoryDatabase` Dexie instance stores three related tables:
10
  1. `searches` โ€“ canonical log of each query plus hydrated results payloads (text, image, run metadata, source, pin state).
11
  2. `llmResponses` โ€“ snapshot of every AI answer tied to its originating search run, enabling restoration of the assistant output.
12
  3. `chatHistory` โ€“ chronological record of chat turns (user/assistant roles) scoped by `conversationId` (the `searchRunId`).
 
14
  All three tables share a `searchRunId` foreign key so restoring a specific run can bring back the search results, saved AI response, and chat transcript atomically.
15
 
16
  ## Cleanup Guarantees
 
17
 
18
+ To keep IndexedDB lean, inserts trigger `performCleanup()`, which enforces:
19
  - **Retention window** โ€“ non-pinned entries older than `historyRetentionDays` are deleted in batches of 100.
20
  - **Max entries** โ€“ if the total exceeds `historyMaxEntries`, the oldest unpinned rows past the limit are removed.
21
  - **Pin protection** โ€“ pinned entries are skipped by both retention and max-entry sweeps.
 
23
  Cleanup is gated by `historyAutoCleanup`, so advanced users can opt out. These knobs come from the settings store and can be updated at runtime.
24
 
25
  ## Hook API
 
26
 
27
+ `useSearchHistory` exposes the canonical API surface for components:
28
  - Fetches recent searches (optionally paginated) and exposes filtered/grouped views.
29
  - Provides fuzzy search, pin toggling, delete/clear operations, and error retry helpers.
30
  - Keeps derived state such as grouped sections (Today/Yesterday/etc.) in sync with the settings flag `historyGroupByDate`.
 
33
  Because the hook writes via Dexie directly, any component can call `addSearchToHistory` to persist new runs immediately after text or image searches resolve.
34
 
35
  ## UI Surfaces
36
+
37
  - **History Drawer** โ€“ wraps the hook data in a drawer with filtering, pin/delete affordances, grouped stacks, and an Analytics tab powered by `SearchStats` in compact mode. The drawer guards against disabled history settings and mirrors the hook filtered list in real time.
38
  - **History Button** โ€“ lazy-loads the drawer and logs open/close events for observability.
39
  - **History Restore** โ€“ the `useHistoryRestore` hook recreates full search state (query string, search results, AI response, and chat transcript) when a user replays an entry.
40
 
41
  ## Settings Surface
42
+
43
  `HistorySettings` wires Mantine form controls to the settings pub/sub so users can toggle history, adjust retention/max entries, and clear all rows (with confirmation + toast feedback). It relies on the same hook to know when the table is empty before enabling destructive actions.
44
 
45
  ## Analytics Integration
46
+
47
  The History Drawer Analytics tab renders the compact `SearchStats` cards scoped to `period="all"`, giving total searches, daily averages, and most active hours pulled from the same IndexedDB data. Keeping analytics co-located ensures the metrics share the hook filtering and cleanup guarantees.
48
 
49
  ## Search Cache System
 
51
  Alongside the history database, MiniSearch uses a separate `SearchCacheDatabase` for temporary result caching:
52
 
53
  **Schema:**
54
+ | Store | Key | Index | Entry Type |
55
+ | -------------------- | ------------ | --------- | ------------------ |
56
+ | `textSearchHistory` | hashed query | timestamp | `TextSearchCache` |
57
  | `imageSearchHistory` | hashed query | timestamp | `ImageSearchCache` |
58
 
59
  **Configuration:**
60
+ | Parameter | Value | Description |
61
+ | ------------------- | ---------- | ------------------------------------------------------------------------------------- |
62
+ | TTL | 15 minutes | Freshness window: a hit inside it skips the network |
63
+ | MAX_STALE_RETENTION | 24 hours | How long an expired entry is kept, so a failed live search can serve it flagged stale |
64
+ | MAX_ENTRIES | 100 | Maximum cached queries per store |
65
+ | PRUNE_INTERVAL | 10 writes | Cache writes between LRU prune passes |
66
+ | REQUEST_TIMEOUT | 30,000 ms | Search request timeout |
67
 
68
  **Performance Metrics:** The `cacheMetrics` object tracks hits/misses per search type and logs hit rates every 10 operations. Query hashing produces deterministic cache keys for reliable lookups.
69
 
 
71
 
72
  Lightweight state persisted via `createLocalStoragePubSub`:
73
 
74
+ | Key | Type | Default | Purpose |
75
+ | ------------------------ | ---------- | ----------------- | -------------------------- |
76
+ | `settings` | `Settings` | `defaultSettings` | App configuration |
77
+ | `querySuggestions` | `string[]` | `[]` | Search suggestion pool |
78
+ | `lastSearchTokenHash` | `string` | `""` | Cached security token hash |
79
+ | `menuExpandedAccordions` | `string[]` | `[]` | Menu UI state |
80
 
81
  ## Performance Measurement
82
 
docs/security.md CHANGED
@@ -32,7 +32,6 @@ can reach the instance, so keep secrets out of that interface.
32
  ### Search Token Lifecycle
33
 
34
  Every HTTP request from client to backend carries a `token` query parameter for CSRF protection:
35
-
36
  1. **Token Generation**: On first use in a process, `regenerateSearchToken()` draws 32 random bytes and writes them to `{os.tempdir()}/minisearch-token` with `0600`. The file is never read back as a source of truth: a token that survives into a published image is one every container of that build shares, and anyone who pulls the image can read it out of the layer
37
  2. **Client Distribution**: The server holds the token it generated for the life of the process and serves it as `searchToken` in `/api/config`. Each process generates its own, so a restart or a second instance means a new token, which is why a bookmarked or shared search URL stops working and lands on the expired-link page
38
  3. **Per-Request Auth**: Client includes token as `?token=` parameter on all `/search/text`, `/search/images`, `/page-content`, `/thumbnail` and `/inference` requests
@@ -66,7 +65,6 @@ Every HTTP request from client to backend carries a `token` query parameter for
66
  ## Supply Chain: Native & WebAssembly Modules
67
 
68
  The full trust model lives in [`.github/SECURITY.md`](../.github/SECURITY.md) ("Native & WebAssembly Module Trust Model"); the operational parts are mirrored here.
69
-
70
  - **Exact pins**: the native/WASM dependencies โ€” `onnxruntime-node@1.29.0`, `@wllama/wllama@3.6.1`, `@huggingface/tokenizers@0.2.0`, `hash-wasm@4.12.0` and `@moonshine-ai/moonshine-wasm@0.1.5` โ€” are declared with single exact versions in `package.json` (no caret ranges), and `package-lock.json` pins each tarball's SHA-512 integrity hash
71
  - **Install-script policy**: `package.json#allowScripts` carries an explicit decision for every package in the tree that ships an install script โ€” `onnxruntime-node` **denied** (its postinstall downloads CUDA 12 nupkgs unpacked by symlink-following `adm-zip`, GHSA-vwc7-r8mq-g2x9; both inference services run `executionProviders: ["cpu"]` and the CPU runtime ships in the tarball), `protobufjs` **denied** (its postinstall only prints a version-scheme advisory), `fsevents@2.3.3` **allowed** (the optional macOS file watcher must compile at install)
72
  - **Strict mode**: `.npmrc` sets `strict-allow-scripts = true`, so an install script with no recorded decision fails `npm ci` instead of warning; the `.npmrc` layer itself can only allow, never deny, which is why the policy lives in `package.json`
@@ -87,23 +85,23 @@ The full trust model lives in [`.github/SECURITY.md`](../.github/SECURITY.md) ("
87
 
88
  ## Server-Side Security Modules
89
 
90
- | Module | Purpose |
91
- |--------|---------|
92
- | `server/searchToken.ts` | Generates the CSRF token per process and records it in `{tempdir}/minisearch-token` |
93
- | `server/verifiedTokens.ts` | In-memory `Set<string>` of verified session tokens |
94
- | `server/rejectedTokens.ts` | Bounded in-memory set of tokens that already failed a completed verification, so a replay skips the second argon2 check |
95
- | `server/searchesSinceLastRestart.ts` | In-memory counters for aggregate search outcomes (text/image search totals, and how often searches came back empty or were fully discarded), plus per-engine failure counts, reported on `/status`; records no query, URL, host, or per-search timestamp, and stores a failure kind classified by `server/webSearchService.ts` rather than SearXNG's reason string |
96
- | `server/pageReadsSinceLastRestart.ts` | In-memory aggregate counters for pages read for grounding (outcomes, durations, passage ratios), reported on `/status`; records no query, URL, host, or per-read timestamp |
97
- | `server/pageReadHostBreaker.ts` | Per-host circuit breaker for page reads: a host that refused the last three reads is skipped for five minutes, then probed with one read; holds host names in memory only, reset on restart, and reports counts alone |
98
- | `server/searchEndpointServerHook.ts` | Proxies text/image search to SearXNG after token verification (via `handleTokenVerification`), and serves fallback text results when SearXNG has failed and `SEARCH_FALLBACK_ENABLED` is on |
99
- | `server/fallbackSearchService.ts` | Second text-search source, consulted only after SearXNG has failed: reads `SEARCH_FALLBACK_ENABLED`, `SEARCH_FALLBACK_PROVIDERS` and the per-provider keys at call time, and refuses to start on an unknown provider name or a removed variable; runs the JSON-RPC exchange against `search.parallel.ai` and You.com's MCP endpoint in a random order per search, one after the other until one returns results, under one shared 15-second budget; pauses a provider that answered 429 until its `Retry-After`; and maps the reply into the tuples the SearXNG path produces; records no query |
100
- | `server/verifyTokenAndRateLimit.ts` | Verifies the Argon2 token hash and enforces rate limiting (10 requests per 10 seconds, shared by the search, page-content and inference endpoints; a separate 60-per-10-seconds budget for `/thumbnail`) |
101
- | `server/handleTokenVerification.ts` | Middleware bridge that calls `verifyTokenAndRateLimit` and writes 400/401/429 error responses for the search, page-content, thumbnail and inference endpoints |
102
- | `server/configEndpointServerHook.ts` | Serves the non-secret runtime config at `/api/config`, including whether access keys are enabled |
103
- | `server/utils/publicUrl.ts` | Rejects non-HTTP schemes and hosts resolving into private, loopback, link-local or reserved ranges before the server fetches a client-supplied URL; `resolvePublicUrlAndAddress` also hands back the vetted IP so the caller can pin the connection to it |
104
- | `server/utils/pinnedFetch.ts` | Fetches a vetted URL over a socket pinned to the vetted IP, with the original hostname preserved for TLS SNI, certificate verification and the `Host` header, so the address checked is the address reached (closes the DNS-rebinding window between check and connect) |
105
- | `server/pageContentEndpointServerHook.ts` | Reads result pages at `/page-content` after token verification, capped at 6 URLs per request |
106
- | `server/thumbnailEndpointServerHook.ts` | Serves one search-result thumbnail at a time at `/thumbnail` after token verification, connecting only to the vetted pinned address, with an in-process LRU in front of the upstream host |
107
 
108
  ### Server-Side Fetching of Client-Supplied URLs
109
 
@@ -144,22 +142,22 @@ the server's memory.
144
  Audited 2026-09-17 on branch `security/thumbnail-ssrf-audit`. Each item of
145
  the issue's checklist, answered from the code:
146
 
147
- | Checklist item | Status | Where |
148
- |---|---|---|
149
- | Blocks private ranges (10/8, 172.16/12, 192.168/16) | Yes | `BLOCKED_CIDRS` / `isBlockedAddress`, `server/utils/publicUrl.ts` |
150
- | Blocks link-local (169.254.0.0/16) | Yes | same table |
151
- | Blocks loopback (127/8, `::1`) | Yes | same table |
152
- | Blocks cloud metadata (169.254.169.254) | Yes | covered by the link-local block |
153
- | Blocks IPv4-mapped IPv4-compatible IPv6 spellings | Yes | IPv4 blocks are matched in mapped form; the IPv4-compatible `::/96` is refused wholesale, as are NAT64/6to4/Teredo, whose embedded IPv4 cannot be vetted |
154
- | Blocks DNS rebinding | Yes โ€” this was the gap this issue closed | validate-then-pin: `resolvePublicUrlAndAddress` returns the vetted IP and `requestPinnedToVettedAddress` (`server/utils/pinnedFetch.ts`) connects to it without re-resolving; hostname is kept for SNI, certificate verification and `Host` |
155
- | URL length limited | Yes | `MAX_THUMBNAIL_URL_LENGTH` (2048), refused before any work |
156
- | Only HTTP/HTTPS schemes | Yes | `resolvePublicUrlAndAddress` |
157
- | Redirects safe (no redirect to a private IP) | Yes | followed by hand in `fetchThumbnail`; every hop re-validated under one shared deadline |
158
- | Content-Type checked to be an image | Yes | raster-only allowlist in `thumbnailEndpointServerHook.ts`; SVG is excluded because served same-origin it is a script-carrying document |
159
- | Response size limited | Yes | `MAX_THUMBNAIL_BYTES` (500 KB) via `readCappedStream` |
160
- | Timeout configured | Yes | `THUMBNAIL_TIMEOUT_MS` (3 s), shared across DNS and every hop |
161
- | Rate limited per client | Yes | `thumbnailRateLimiter`, a dedicated 60-per-10-seconds budget keyed on client IP |
162
- | Global rate limit | No, by design | the per-client limiter plus the shared token gate bound the total; a global cap would let one caller's grid starve every other user's tiles |
163
 
164
  Residual risk after the change: the endpoint still fetches arbitrary public
165
  URLs, so a caller can use it to GET any public host โ€” bounded by the rate
 
32
  ### Search Token Lifecycle
33
 
34
  Every HTTP request from client to backend carries a `token` query parameter for CSRF protection:
 
35
  1. **Token Generation**: On first use in a process, `regenerateSearchToken()` draws 32 random bytes and writes them to `{os.tempdir()}/minisearch-token` with `0600`. The file is never read back as a source of truth: a token that survives into a published image is one every container of that build shares, and anyone who pulls the image can read it out of the layer
36
  2. **Client Distribution**: The server holds the token it generated for the life of the process and serves it as `searchToken` in `/api/config`. Each process generates its own, so a restart or a second instance means a new token, which is why a bookmarked or shared search URL stops working and lands on the expired-link page
37
  3. **Per-Request Auth**: Client includes token as `?token=` parameter on all `/search/text`, `/search/images`, `/page-content`, `/thumbnail` and `/inference` requests
 
65
  ## Supply Chain: Native & WebAssembly Modules
66
 
67
  The full trust model lives in [`.github/SECURITY.md`](../.github/SECURITY.md) ("Native & WebAssembly Module Trust Model"); the operational parts are mirrored here.
 
68
  - **Exact pins**: the native/WASM dependencies โ€” `onnxruntime-node@1.29.0`, `@wllama/wllama@3.6.1`, `@huggingface/tokenizers@0.2.0`, `hash-wasm@4.12.0` and `@moonshine-ai/moonshine-wasm@0.1.5` โ€” are declared with single exact versions in `package.json` (no caret ranges), and `package-lock.json` pins each tarball's SHA-512 integrity hash
69
  - **Install-script policy**: `package.json#allowScripts` carries an explicit decision for every package in the tree that ships an install script โ€” `onnxruntime-node` **denied** (its postinstall downloads CUDA 12 nupkgs unpacked by symlink-following `adm-zip`, GHSA-vwc7-r8mq-g2x9; both inference services run `executionProviders: ["cpu"]` and the CPU runtime ships in the tarball), `protobufjs` **denied** (its postinstall only prints a version-scheme advisory), `fsevents@2.3.3` **allowed** (the optional macOS file watcher must compile at install)
70
  - **Strict mode**: `.npmrc` sets `strict-allow-scripts = true`, so an install script with no recorded decision fails `npm ci` instead of warning; the `.npmrc` layer itself can only allow, never deny, which is why the policy lives in `package.json`
 
85
 
86
  ## Server-Side Security Modules
87
 
88
+ | Module | Purpose |
89
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
90
+ | `server/searchToken.ts` | Generates the CSRF token per process and records it in `{tempdir}/minisearch-token` |
91
+ | `server/verifiedTokens.ts` | In-memory `Set<string>` of verified session tokens |
92
+ | `server/rejectedTokens.ts` | Bounded in-memory set of tokens that already failed a completed verification, so a replay skips the second argon2 check |
93
+ | `server/searchesSinceLastRestart.ts` | In-memory counters for aggregate search outcomes (text/image search totals, and how often searches came back empty or were fully discarded), plus per-engine failure counts, reported on `/status`; records no query, URL, host, or per-search timestamp, and stores a failure kind classified by `server/webSearchService.ts` rather than SearXNG's reason string |
94
+ | `server/pageReadsSinceLastRestart.ts` | In-memory aggregate counters for pages read for grounding (outcomes, durations, passage ratios), reported on `/status`; records no query, URL, host, or per-read timestamp |
95
+ | `server/pageReadHostBreaker.ts` | Per-host circuit breaker for page reads: a host that refused the last three reads is skipped for five minutes, then probed with one read; holds host names in memory only, reset on restart, and reports counts alone |
96
+ | `server/searchEndpointServerHook.ts` | Proxies text/image search to SearXNG after token verification (via `handleTokenVerification`), and serves fallback text results when SearXNG has failed and `SEARCH_FALLBACK_ENABLED` is on |
97
+ | `server/fallbackSearchService.ts` | Second text-search source, consulted only after SearXNG has failed: reads `SEARCH_FALLBACK_ENABLED`, `SEARCH_FALLBACK_PROVIDERS` and the per-provider keys at call time, and refuses to start on an unknown provider name or a removed variable; runs the JSON-RPC exchange against `search.parallel.ai` and You.com's MCP endpoint in a random order per search, one after the other until one returns results, under one shared 15-second budget; pauses a provider that answered 429 until its `Retry-After`; and maps the reply into the tuples the SearXNG path produces; records no query |
98
+ | `server/verifyTokenAndRateLimit.ts` | Verifies the Argon2 token hash and enforces rate limiting (10 requests per 10 seconds, shared by the search, page-content and inference endpoints; a separate 60-per-10-seconds budget for `/thumbnail`) |
99
+ | `server/handleTokenVerification.ts` | Middleware bridge that calls `verifyTokenAndRateLimit` and writes 400/401/429 error responses for the search, page-content, thumbnail and inference endpoints |
100
+ | `server/configEndpointServerHook.ts` | Serves the non-secret runtime config at `/api/config`, including whether access keys are enabled |
101
+ | `server/utils/publicUrl.ts` | Rejects non-HTTP schemes and hosts resolving into private, loopback, link-local or reserved ranges before the server fetches a client-supplied URL; `resolvePublicUrlAndAddress` also hands back the vetted IP so the caller can pin the connection to it |
102
+ | `server/utils/pinnedFetch.ts` | Fetches a vetted URL over a socket pinned to the vetted IP, with the original hostname preserved for TLS SNI, certificate verification and the `Host` header, so the address checked is the address reached (closes the DNS-rebinding window between check and connect) |
103
+ | `server/pageContentEndpointServerHook.ts` | Reads result pages at `/page-content` after token verification, capped at 6 URLs per request |
104
+ | `server/thumbnailEndpointServerHook.ts` | Serves one search-result thumbnail at a time at `/thumbnail` after token verification, connecting only to the vetted pinned address, with an in-process LRU in front of the upstream host |
105
 
106
  ### Server-Side Fetching of Client-Supplied URLs
107
 
 
142
  Audited 2026-09-17 on branch `security/thumbnail-ssrf-audit`. Each item of
143
  the issue's checklist, answered from the code:
144
 
145
+ | Checklist item | Status | Where |
146
+ | --------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
147
+ | Blocks private ranges (10/8, 172.16/12, 192.168/16) | Yes | `BLOCKED_CIDRS` / `isBlockedAddress`, `server/utils/publicUrl.ts` |
148
+ | Blocks link-local (169.254.0.0/16) | Yes | same table |
149
+ | Blocks loopback (127/8, `::1`) | Yes | same table |
150
+ | Blocks cloud metadata (169.254.169.254) | Yes | covered by the link-local block |
151
+ | Blocks IPv4-mapped IPv4-compatible IPv6 spellings | Yes | IPv4 blocks are matched in mapped form; the IPv4-compatible `::/96` is refused wholesale, as are NAT64/6to4/Teredo, whose embedded IPv4 cannot be vetted |
152
+ | Blocks DNS rebinding | Yes โ€” this was the gap this issue closed | validate-then-pin: `resolvePublicUrlAndAddress` returns the vetted IP and `requestPinnedToVettedAddress` (`server/utils/pinnedFetch.ts`) connects to it without re-resolving; hostname is kept for SNI, certificate verification and `Host` |
153
+ | URL length limited | Yes | `MAX_THUMBNAIL_URL_LENGTH` (2048), refused before any work |
154
+ | Only HTTP/HTTPS schemes | Yes | `resolvePublicUrlAndAddress` |
155
+ | Redirects safe (no redirect to a private IP) | Yes | followed by hand in `fetchThumbnail`; every hop re-validated under one shared deadline |
156
+ | Content-Type checked to be an image | Yes | raster-only allowlist in `thumbnailEndpointServerHook.ts`; SVG is excluded because served same-origin it is a script-carrying document |
157
+ | Response size limited | Yes | `MAX_THUMBNAIL_BYTES` (500 KB) via `readCappedStream` |
158
+ | Timeout configured | Yes | `THUMBNAIL_TIMEOUT_MS` (3 s), shared across DNS and every hop |
159
+ | Rate limited per client | Yes | `thumbnailRateLimiter`, a dedicated 60-per-10-seconds budget keyed on client IP |
160
+ | Global rate limit | No, by design | the per-client limiter plus the shared token gate bound the total; a global cap would let one caller's grid starve every other user's tiles |
161
 
162
  Residual risk after the change: the endpoint still fetches arbitrary public
163
  URLs, so a caller can use it to GET any public host โ€” bounded by the rate
docs/ui-components.md CHANGED
@@ -8,11 +8,11 @@ MiniSearch uses a **PubSub-based reactive architecture**. Components subscribe t
8
 
9
  Each PubSub channel is a three-element tuple returned by `createPubSub`:
10
 
11
- | Index | Name | Role |
12
- |-------|------|------|
13
- | `[0]` | `update*` | Setter โ€” publishes a new value to all subscribers |
14
  | `[1]` | `onValueChange` / `subscribe*` / `listen*` | Subscription registration โ€” receives every future value |
15
- | `[2]` | `get*` | Getter โ€” reads the current value synchronously |
16
 
17
  These are destructured at module level in `pubSub.ts` and exported under descriptive names (e.g., `updateTextGenerationState`, `listenToSettingsChanges`, `getQuery`).
18
 
@@ -21,7 +21,7 @@ These are destructured at module level in `pubSub.ts` and exported under descrip
21
  const query = usePubSub(queryPubSub);
22
 
23
  // Any module can update state
24
- updateQuery('new query');
25
 
26
  // All subscribers automatically re-render
27
  ```
@@ -35,7 +35,6 @@ updateQuery('new query');
35
  ### localStorage Persistence
36
 
37
  Some state must survive page reloads. The `createLocalStoragePubSub` helper wraps `createPubSub` with two behaviors:
38
-
39
  1. **Hydration** โ€” On first call, reads existing value from `localStorage` via `localStorage.getItem(key)`. If a stored JSON string is found, it is parsed and used as the initial value; otherwise the default value is used
40
  2. **Persistence** โ€” A subscriber is immediately registered on the inner PubSub that calls `localStorage.setItem` with the JSON-serialized new value on every state change
41
 
@@ -45,10 +44,10 @@ Channels using this pattern: `settingsPubSub`, `querySuggestionsPubSub`, `lastSe
45
 
46
  Streaming LLM output produces token-by-token state changes that would overwhelm React's rendering pipeline. Two channels apply `throttle` from `throttleit` to cap subscriber notification rate to ~12 updates/sec (83.3ms interval):
47
 
48
- | Export | Raw Updater | Throttle Interval |
49
- |--------|-------------|-------------------|
50
- | `updateResponse` | `responsePubSub[0]` | 1000 / 12 ms |
51
- | `updateReasoningContent` | `reasoningContentPubSub[0]` | 1000 / 12 ms |
52
 
53
  Callers write streaming token output directly to these exports without awareness of internal throttling.
54
 
@@ -56,43 +55,43 @@ Callers write streaming token output directly to these exports without awareness
56
 
57
  Three channels register built-in side-effect subscribers at module load time, independently of any React component lifecycle, for automatic logging via `addLogEntry`:
58
 
59
- | Channel | Side Effect |
60
- |---------|-------------|
61
  | `textGenerationStatePubSub` | Logs state transitions |
62
- | `textSearchStatePubSub` | Logs state transitions |
63
- | `imageSearchStatePubSub` | Logs state transitions |
64
 
65
  ## PubSub Channel Reference
66
 
67
  All state channels are defined in `client/modules/pubSub.ts`:
68
 
69
- | Channel | Type | Description | Primary Consumers |
70
- |---------|------|-------------|-------------------|
71
- | `queryPubSub` | `string` | Current search query | SearchForm, SearchButton |
72
- | `responsePubSub` | `string` | AI response content (throttled: 12/sec) | AiResponseSection |
73
- | `textToSpeechStatePubSub` | `TextToSpeechState` | Whether an answer is being read aloud: `"idle" \| "speaking"` | AiResponseContent |
74
- | `reasoningContentPubSub` | `string` | AI reasoning/thinking content (throttled: 12/sec) | AiResponseSection |
75
- | `settingsPubSub` | `Settings` | Application settings | SettingsForm, various components |
76
- | `textSearchResultsPubSub` | `TextSearchResults` | Text search results | SearchResultsSection |
77
- | `llmTextSearchResultsPubSub` | `TextSearchResults` | LLM-reranked text results | Internal use |
78
- | `imageSearchResultsPubSub` | `ImageSearchResults` | Image search results | ImageResultsSection |
79
- | `textSearchStatePubSub` | `SearchState` | Text search state: `"idle" \| "running" \| "failed" \| "completed"` | SearchResultsSection, LoadingIndicators |
80
- | `textSearchStalePubSub` | `boolean` | Whether the text results on screen came from the cache because the live search failed | TextSearchResults |
81
- | `imageSearchStatePubSub` | `SearchState` | Image search state: `"idle" \| "running" \| "failed" \| "completed"` | ImageResultsSection |
82
- | `textGenerationStatePubSub` | `TextGenerationState` | AI generation state | AiResponseSection, StatusIndicators |
83
- | `modelLoadingProgressPubSub` | `number` | Model download progress (0-100) | AiResponseSection |
84
- | `modelSizeInMegabytesPubSub` | `number` | Model size in MB for progress calc | AiResponseSection |
85
- | `chatMessagesPubSub` | `ChatMessage[]` | Chat conversation | ChatInterface |
86
- | `chatInputPubSub` | `string` | Current chat input content | ChatInputArea |
87
- | `chatGenerationStatePubSub` | `{isGeneratingResponse, isGeneratingFollowUpQuestion}` | Chat generation states. Each flag has one owner: the send and regenerate handlers write `isGeneratingResponse`, `regenerateFollowUpQuestion` writes `isGeneratingFollowUpQuestion`, and every writer merges `getChatGenerationState()` so a concurrent flow is not clobbered. A boolean cannot represent two follow-up calls at once, so each carries an invocation token, and a completion that is no longer the current one writes nothing. Unmounting is the exception to the merge: it replaces both flags outright, bumps the token, and stops the response writers touching the channel at all, so an orphaned flow cannot write into the instance that replaced it | ChatInterface |
88
- | `conversationSummaryPubSub` | `{id, summary}` | Rolling conversation summary | TextGeneration module |
89
- | `followUpQuestionPubSub` | `string` | Generated follow-up question | AiResponseSection |
90
- | `suppressNextFollowUpPubSub` | `boolean` | Flag to skip next follow-up | FollowUpQuestions module |
91
- | `isRestoringFromHistoryPubSub` | `boolean` | History restoration in progress | SearchForm, components |
92
- | `menuExpandedAccordionsPubSub` | `string[]` | Expanded menu accordion IDs | MenuDrawer |
93
- | `querySuggestionsPubSub` | `string[]` | Query suggestions history | SearchForm |
94
- | `lastSearchTokenHashPubSub` | `string` | Hash of last search token | Security/validation |
95
- | `logEntriesPubSub` | `LogEntry[]` | Application log entries | LogsModal, ShowLogsButton |
96
 
97
  ## Component Hierarchy
98
 
@@ -190,12 +189,12 @@ that appends each transcript without clobbering what the user typed meanwhile.
190
 
191
  **Props:**
192
 
193
- | Prop | Role |
194
- |---|---|
195
- | `getValue` / `setValue` | Reads and replaces the field's content. The caller owns the field, so the button never touches the DOM itself |
196
- | `labelScope` | Names the field in the accessible labels, as the object of "Dictate" and "Stop dictating": `"the search query"`, `"a follow-up question"` |
197
- | `rightOffset` | Distance from the right edge of the positioned ancestor, for a field that already has a button at its edge |
198
- | `disabled` | Blocks a new session and stops one already running, for a field that has gone read-only |
199
 
200
  The button renders itself absolutely positioned, so each caller wraps its field
201
  in a `position: relative` element and reserves `dictationButtonWidth` of right
@@ -239,17 +238,17 @@ function SearchResultsSection() {
239
 
240
  **State Machine (`textGenerationStatePubSub`):**
241
 
242
- | State | Description | UI Display |
243
- |-------|-------------|------------|
244
- | `idle` | No active generation | Hidden or empty |
245
  | `awaitingModelDownloadAllowance` | Waiting for user to confirm model download | `AiModelDownloadAllowanceContent` confirmation prompt |
246
- | `loadingModel` | Downloading or initializing AI model | `LoadingModelContent` with progress |
247
- | `awaitingSearchResults` | Waiting for search to complete | `PreparingContent` indicator |
248
- | `preparingToGenerate` | Search done, response not yet started | `PreparingContent` indicator |
249
- | `generating` | Streaming response tokens | Active response with streaming text |
250
- | `interrupted` | Generation manually stopped by user | Response retained, with a yellow "Interrupted" badge |
251
- | `completed` | Full response received | Complete response with chat interface |
252
- | `failed` | Error occurred | Error message with retry option |
253
 
254
  **Reasoning Content Extraction:**
255
 
@@ -303,7 +302,10 @@ function AiResponseSection() {
303
  **Persistence:**
304
  ```typescript
305
  // client/modules/pubSub.ts
306
- export const settingsPubSub = createLocalStoragePubSub('settings', defaultSettings);
 
 
 
307
  ```
308
 
309
  `createLocalStoragePubSub` registers the localStorage-writing subscriber internally, so components just read and write `settingsPubSub` like any other channel.
@@ -425,7 +427,8 @@ const [value, setValue] = usePubSub(channel);
425
 
426
  Manages search history from IndexedDB:
427
  ```typescript
428
- const { searchHistory, groupedSearches, deleteEntry, togglePin } = useSearchHistory();
 
429
  ```
430
 
431
  ### useDrawerState
 
8
 
9
  Each PubSub channel is a three-element tuple returned by `createPubSub`:
10
 
11
+ | Index | Name | Role |
12
+ | ----- | ------------------------------------------ | ------------------------------------------------------- |
13
+ | `[0]` | `update*` | Setter โ€” publishes a new value to all subscribers |
14
  | `[1]` | `onValueChange` / `subscribe*` / `listen*` | Subscription registration โ€” receives every future value |
15
+ | `[2]` | `get*` | Getter โ€” reads the current value synchronously |
16
 
17
  These are destructured at module level in `pubSub.ts` and exported under descriptive names (e.g., `updateTextGenerationState`, `listenToSettingsChanges`, `getQuery`).
18
 
 
21
  const query = usePubSub(queryPubSub);
22
 
23
  // Any module can update state
24
+ updateQuery("new query");
25
 
26
  // All subscribers automatically re-render
27
  ```
 
35
  ### localStorage Persistence
36
 
37
  Some state must survive page reloads. The `createLocalStoragePubSub` helper wraps `createPubSub` with two behaviors:
 
38
  1. **Hydration** โ€” On first call, reads existing value from `localStorage` via `localStorage.getItem(key)`. If a stored JSON string is found, it is parsed and used as the initial value; otherwise the default value is used
39
  2. **Persistence** โ€” A subscriber is immediately registered on the inner PubSub that calls `localStorage.setItem` with the JSON-serialized new value on every state change
40
 
 
44
 
45
  Streaming LLM output produces token-by-token state changes that would overwhelm React's rendering pipeline. Two channels apply `throttle` from `throttleit` to cap subscriber notification rate to ~12 updates/sec (83.3ms interval):
46
 
47
+ | Export | Raw Updater | Throttle Interval |
48
+ | ------------------------ | --------------------------- | ----------------- |
49
+ | `updateResponse` | `responsePubSub[0]` | 1000 / 12 ms |
50
+ | `updateReasoningContent` | `reasoningContentPubSub[0]` | 1000 / 12 ms |
51
 
52
  Callers write streaming token output directly to these exports without awareness of internal throttling.
53
 
 
55
 
56
  Three channels register built-in side-effect subscribers at module load time, independently of any React component lifecycle, for automatic logging via `addLogEntry`:
57
 
58
+ | Channel | Side Effect |
59
+ | --------------------------- | ---------------------- |
60
  | `textGenerationStatePubSub` | Logs state transitions |
61
+ | `textSearchStatePubSub` | Logs state transitions |
62
+ | `imageSearchStatePubSub` | Logs state transitions |
63
 
64
  ## PubSub Channel Reference
65
 
66
  All state channels are defined in `client/modules/pubSub.ts`:
67
 
68
+ | Channel | Type | Description | Primary Consumers |
69
+ | ------------------------------ | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
70
+ | `queryPubSub` | `string` | Current search query | SearchForm, SearchButton |
71
+ | `responsePubSub` | `string` | AI response content (throttled: 12/sec) | AiResponseSection |
72
+ | `textToSpeechStatePubSub` | `TextToSpeechState` | Whether an answer is being read aloud: `"idle" \| "speaking"` | AiResponseContent |
73
+ | `reasoningContentPubSub` | `string` | AI reasoning/thinking content (throttled: 12/sec) | AiResponseSection |
74
+ | `settingsPubSub` | `Settings` | Application settings | SettingsForm, various components |
75
+ | `textSearchResultsPubSub` | `TextSearchResults` | Text search results | SearchResultsSection |
76
+ | `llmTextSearchResultsPubSub` | `TextSearchResults` | LLM-reranked text results | Internal use |
77
+ | `imageSearchResultsPubSub` | `ImageSearchResults` | Image search results | ImageResultsSection |
78
+ | `textSearchStatePubSub` | `SearchState` | Text search state: `"idle" \| "running" \| "failed" \| "completed"` | SearchResultsSection, LoadingIndicators |
79
+ | `textSearchStalePubSub` | `boolean` | Whether the text results on screen came from the cache because the live search failed | TextSearchResults |
80
+ | `imageSearchStatePubSub` | `SearchState` | Image search state: `"idle" \| "running" \| "failed" \| "completed"` | ImageResultsSection |
81
+ | `textGenerationStatePubSub` | `TextGenerationState` | AI generation state | AiResponseSection, StatusIndicators |
82
+ | `modelLoadingProgressPubSub` | `number` | Model download progress (0-100) | AiResponseSection |
83
+ | `modelSizeInMegabytesPubSub` | `number` | Model size in MB for progress calc | AiResponseSection |
84
+ | `chatMessagesPubSub` | `ChatMessage[]` | Chat conversation | ChatInterface |
85
+ | `chatInputPubSub` | `string` | Current chat input content | ChatInputArea |
86
+ | `chatGenerationStatePubSub` | `{isGeneratingResponse, isGeneratingFollowUpQuestion}` | Chat generation states. Each flag has one owner: the send and regenerate handlers write `isGeneratingResponse`, `regenerateFollowUpQuestion` writes `isGeneratingFollowUpQuestion`, and every writer merges `getChatGenerationState()` so a concurrent flow is not clobbered. A boolean cannot represent two follow-up calls at once, so each carries an invocation token, and a completion that is no longer the current one writes nothing. Unmounting is the exception to the merge: it replaces both flags outright, bumps the token, and stops the response writers touching the channel at all, so an orphaned flow cannot write into the instance that replaced it | ChatInterface |
87
+ | `conversationSummaryPubSub` | `{id, summary}` | Rolling conversation summary | TextGeneration module |
88
+ | `followUpQuestionPubSub` | `string` | Generated follow-up question | AiResponseSection |
89
+ | `suppressNextFollowUpPubSub` | `boolean` | Flag to skip next follow-up | FollowUpQuestions module |
90
+ | `isRestoringFromHistoryPubSub` | `boolean` | History restoration in progress | SearchForm, components |
91
+ | `menuExpandedAccordionsPubSub` | `string[]` | Expanded menu accordion IDs | MenuDrawer |
92
+ | `querySuggestionsPubSub` | `string[]` | Query suggestions history | SearchForm |
93
+ | `lastSearchTokenHashPubSub` | `string` | Hash of last search token | Security/validation |
94
+ | `logEntriesPubSub` | `LogEntry[]` | Application log entries | LogsModal, ShowLogsButton |
95
 
96
  ## Component Hierarchy
97
 
 
189
 
190
  **Props:**
191
 
192
+ | Prop | Role |
193
+ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
194
+ | `getValue` / `setValue` | Reads and replaces the field's content. The caller owns the field, so the button never touches the DOM itself |
195
+ | `labelScope` | Names the field in the accessible labels, as the object of "Dictate" and "Stop dictating": `"the search query"`, `"a follow-up question"` |
196
+ | `rightOffset` | Distance from the right edge of the positioned ancestor, for a field that already has a button at its edge |
197
+ | `disabled` | Blocks a new session and stops one already running, for a field that has gone read-only |
198
 
199
  The button renders itself absolutely positioned, so each caller wraps its field
200
  in a `position: relative` element and reserves `dictationButtonWidth` of right
 
238
 
239
  **State Machine (`textGenerationStatePubSub`):**
240
 
241
+ | State | Description | UI Display |
242
+ | -------------------------------- | ------------------------------------------ | ----------------------------------------------------- |
243
+ | `idle` | No active generation | Hidden or empty |
244
  | `awaitingModelDownloadAllowance` | Waiting for user to confirm model download | `AiModelDownloadAllowanceContent` confirmation prompt |
245
+ | `loadingModel` | Downloading or initializing AI model | `LoadingModelContent` with progress |
246
+ | `awaitingSearchResults` | Waiting for search to complete | `PreparingContent` indicator |
247
+ | `preparingToGenerate` | Search done, response not yet started | `PreparingContent` indicator |
248
+ | `generating` | Streaming response tokens | Active response with streaming text |
249
+ | `interrupted` | Generation manually stopped by user | Response retained, with a yellow "Interrupted" badge |
250
+ | `completed` | Full response received | Complete response with chat interface |
251
+ | `failed` | Error occurred | Error message with retry option |
252
 
253
  **Reasoning Content Extraction:**
254
 
 
302
  **Persistence:**
303
  ```typescript
304
  // client/modules/pubSub.ts
305
+ export const settingsPubSub = createLocalStoragePubSub(
306
+ "settings",
307
+ defaultSettings,
308
+ );
309
  ```
310
 
311
  `createLocalStoragePubSub` registers the localStorage-writing subscriber internally, so components just read and write `settingsPubSub` like any other channel.
 
427
 
428
  Manages search history from IndexedDB:
429
  ```typescript
430
+ const { searchHistory, groupedSearches, deleteEntry, togglePin } =
431
+ useSearchHistory();
432
  ```
433
 
434
  ### useDrawerState
eval/README.md CHANGED
@@ -6,7 +6,6 @@ has two parts, both driven by a single fixed golden set (`goldenSet.ts`).
6
 
7
  Some of it runs in the default `npm test` suite (and so in CI); the rest is a
8
  local signal:
9
-
10
  - `metrics.test.ts`, `goldenSet.test.ts`, `promptConstruction.test.ts` run in
11
  the default suite: pure math, golden-set structure, and prompt construction.
12
  No model, no API key.
@@ -20,7 +19,6 @@ change, not an absolute quality score.
20
  ## The golden set
21
 
22
  `goldenSet.ts` is a fixed, hand-curated list of queries. Each entry has:
23
-
24
  - `query`: the search query.
25
  - `results`: realistic candidate results (title, snippet, url), mixed
26
  relevant and irrelevant, the way a real search engine returns them.
 
6
 
7
  Some of it runs in the default `npm test` suite (and so in CI); the rest is a
8
  local signal:
 
9
  - `metrics.test.ts`, `goldenSet.test.ts`, `promptConstruction.test.ts` run in
10
  the default suite: pure math, golden-set structure, and prompt construction.
11
  No model, no API key.
 
19
  ## The golden set
20
 
21
  `goldenSet.ts` is a fixed, hand-curated list of queries. Each entry has:
 
22
  - `query`: the search query.
23
  - `results`: realistic candidate results (title, snippet, url), mixed
24
  relevant and irrelevant, the way a real search engine returns them.
package-lock.json CHANGED
@@ -39,7 +39,7 @@
39
  "http-compression": "^1.0.20",
40
  "keyword-extractor": "^0.3.0",
41
  "node-emoji": "^2.1.3",
42
- "onnxruntime-node": "1.30.0",
43
  "pdf-parse": "^2.0.0",
44
  "postcss": "^8.5.25",
45
  "postcss-preset-mantine": "^1.17.0",
@@ -69,7 +69,7 @@
69
  "@testing-library/react": "^16.0.0",
70
  "@testing-library/user-event": "^14.5.0",
71
  "@types/html-to-text": "^9.0.4",
72
- "@types/node": "^24.0.0",
73
  "@types/react": "^19.0.0",
74
  "@types/react-dom": "^19.0.0",
75
  "@vitest/coverage-v8": "^5.0.0",
@@ -95,13 +95,13 @@
95
  "license": "MIT"
96
  },
97
  "node_modules/@ai-sdk/gateway": {
98
- "version": "4.0.115",
99
- "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.115.tgz",
100
- "integrity": "sha512-Xn+gDWM4Ti2FzYmxhlZtjEuGBGZlOYaQMpwcVRSN4c9qCMUszD5F5TAbkxgLV/tHKeMfEX+WEuTCl6Kkj2r73A==",
101
  "license": "Apache-2.0",
102
  "dependencies": {
103
- "@ai-sdk/provider": "4.0.28",
104
- "@ai-sdk/provider-utils": "5.0.61",
105
  "@vercel/oidc": "3.2.0"
106
  },
107
  "engines": {
@@ -111,26 +111,14 @@
111
  "zod": "^3.25.76 || ^4.1.8"
112
  }
113
  },
114
- "node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider": {
115
- "version": "4.0.28",
116
- "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.28.tgz",
117
- "integrity": "sha512-6ThcmkJQXSMlBrf6mirIjUUS04Q5/kApbYTOzd5lZfYXLa1XPpnDLxP/G96/DKc00fDIUOW9S2ls0mSzb0+l9w==",
118
- "license": "Apache-2.0",
119
- "dependencies": {
120
- "json-schema": "^0.4.0"
121
- },
122
- "engines": {
123
- "node": ">=22"
124
- }
125
- },
126
  "node_modules/@ai-sdk/openai-compatible": {
127
- "version": "3.0.70",
128
- "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-3.0.70.tgz",
129
- "integrity": "sha512-0TefLzj+V7aMLrIIKdk3CKRji+hOu3oOSHeLKJVzr0+q/7rsH4Q56w6RK7GX7csmAd9Urtt097XBTkBwJbpDjA==",
130
  "license": "Apache-2.0",
131
  "dependencies": {
132
- "@ai-sdk/provider": "4.0.28",
133
- "@ai-sdk/provider-utils": "5.0.61"
134
  },
135
  "engines": {
136
  "node": ">=22"
@@ -139,22 +127,10 @@
139
  "zod": "^3.25.76 || ^4.1.8"
140
  }
141
  },
142
- "node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider": {
143
- "version": "4.0.28",
144
- "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.28.tgz",
145
- "integrity": "sha512-6ThcmkJQXSMlBrf6mirIjUUS04Q5/kApbYTOzd5lZfYXLa1XPpnDLxP/G96/DKc00fDIUOW9S2ls0mSzb0+l9w==",
146
- "license": "Apache-2.0",
147
- "dependencies": {
148
- "json-schema": "^0.4.0"
149
- },
150
- "engines": {
151
- "node": ">=22"
152
- }
153
- },
154
  "node_modules/@ai-sdk/provider": {
155
- "version": "4.0.27",
156
- "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.27.tgz",
157
- "integrity": "sha512-TtWwm6SxFiPomlMIbTQa+yhJte+w4/oCsW+wJrJzMC3MqHpAY9e5dJF1Y4HxyKt3W2Csac0KiI76Ckio11TFYw==",
158
  "license": "Apache-2.0",
159
  "dependencies": {
160
  "json-schema": "^0.4.0"
@@ -164,12 +140,12 @@
164
  }
165
  },
166
  "node_modules/@ai-sdk/provider-utils": {
167
- "version": "5.0.61",
168
- "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.61.tgz",
169
- "integrity": "sha512-Y+Z3539UXw0ctFDNepTtG23VqBQHr2A6yI2qUxvQcg75DJYgJoD2F9U2Vp5OgQjX7lxto1BLgFtKuOhzrr52vQ==",
170
  "license": "Apache-2.0",
171
  "dependencies": {
172
- "@ai-sdk/provider": "4.0.28",
173
  "@standard-schema/spec": "^1.1.0",
174
  "@workflow/serde": "4.1.0",
175
  "eventsource-parser": "^3.0.8",
@@ -182,18 +158,6 @@
182
  "zod": "^3.25.76 || ^4.1.8"
183
  }
184
  },
185
- "node_modules/@ai-sdk/provider-utils/node_modules/@ai-sdk/provider": {
186
- "version": "4.0.28",
187
- "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.28.tgz",
188
- "integrity": "sha512-6ThcmkJQXSMlBrf6mirIjUUS04Q5/kApbYTOzd5lZfYXLa1XPpnDLxP/G96/DKc00fDIUOW9S2ls0mSzb0+l9w==",
189
- "license": "Apache-2.0",
190
- "dependencies": {
191
- "json-schema": "^0.4.0"
192
- },
193
- "engines": {
194
- "node": ">=22"
195
- }
196
- },
197
  "node_modules/@ai-sdk/provider-utils/node_modules/undici": {
198
  "version": "7.30.1",
199
  "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
@@ -321,9 +285,9 @@
321
  }
322
  },
323
  "node_modules/@biomejs/biome": {
324
- "version": "2.5.15",
325
- "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.15.tgz",
326
- "integrity": "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==",
327
  "dev": true,
328
  "license": "MIT OR Apache-2.0",
329
  "bin": {
@@ -337,20 +301,56 @@
337
  "url": "https://opencollective.com/biome"
338
  },
339
  "optionalDependencies": {
340
- "@biomejs/cli-darwin-arm64": "2.5.15",
341
- "@biomejs/cli-darwin-x64": "2.5.15",
342
- "@biomejs/cli-linux-arm64": "2.5.15",
343
- "@biomejs/cli-linux-arm64-musl": "2.5.15",
344
- "@biomejs/cli-linux-x64": "2.5.15",
345
- "@biomejs/cli-linux-x64-musl": "2.5.15",
346
- "@biomejs/cli-win32-arm64": "2.5.15",
347
- "@biomejs/cli-win32-x64": "2.5.15"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  }
349
  },
350
  "node_modules/@biomejs/cli-darwin-arm64": {
351
- "version": "2.5.15",
352
- "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.15.tgz",
353
- "integrity": "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==",
354
  "cpu": [
355
  "arm64"
356
  ],
@@ -365,9 +365,9 @@
365
  }
366
  },
367
  "node_modules/@biomejs/cli-darwin-x64": {
368
- "version": "2.5.15",
369
- "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.15.tgz",
370
- "integrity": "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==",
371
  "cpu": [
372
  "x64"
373
  ],
@@ -382,9 +382,9 @@
382
  }
383
  },
384
  "node_modules/@biomejs/cli-linux-arm64": {
385
- "version": "2.5.15",
386
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.15.tgz",
387
- "integrity": "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==",
388
  "cpu": [
389
  "arm64"
390
  ],
@@ -402,9 +402,9 @@
402
  }
403
  },
404
  "node_modules/@biomejs/cli-linux-arm64-musl": {
405
- "version": "2.5.15",
406
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.15.tgz",
407
- "integrity": "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==",
408
  "cpu": [
409
  "arm64"
410
  ],
@@ -422,9 +422,9 @@
422
  }
423
  },
424
  "node_modules/@biomejs/cli-linux-x64": {
425
- "version": "2.5.15",
426
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.15.tgz",
427
- "integrity": "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==",
428
  "cpu": [
429
  "x64"
430
  ],
@@ -442,9 +442,9 @@
442
  }
443
  },
444
  "node_modules/@biomejs/cli-linux-x64-musl": {
445
- "version": "2.5.15",
446
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.15.tgz",
447
- "integrity": "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==",
448
  "cpu": [
449
  "x64"
450
  ],
@@ -462,9 +462,9 @@
462
  }
463
  },
464
  "node_modules/@biomejs/cli-win32-arm64": {
465
- "version": "2.5.15",
466
- "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.15.tgz",
467
- "integrity": "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==",
468
  "cpu": [
469
  "arm64"
470
  ],
@@ -479,9 +479,9 @@
479
  }
480
  },
481
  "node_modules/@biomejs/cli-win32-x64": {
482
- "version": "2.5.15",
483
- "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.15.tgz",
484
- "integrity": "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==",
485
  "cpu": [
486
  "x64"
487
  ],
@@ -746,12 +746,12 @@
746
  "license": "MIT"
747
  },
748
  "node_modules/@huggingface/hub": {
749
- "version": "2.17.8",
750
- "resolved": "https://registry.npmjs.org/@huggingface/hub/-/hub-2.17.8.tgz",
751
- "integrity": "sha512-FzZaxzt0f2pkFQxFB7hxicCRsB3KFDXM8l6VOjisFbJboE7nSAMcCKxxxO3ARoZMFXDFDigFWbYNaVvzB8w2mg==",
752
  "license": "MIT",
753
  "dependencies": {
754
- "@huggingface/tasks": "^0.21.61",
755
  "@huggingface/xetchunk-wasm": "^0.2.0"
756
  },
757
  "bin": {
@@ -765,9 +765,9 @@
765
  }
766
  },
767
  "node_modules/@huggingface/tasks": {
768
- "version": "0.21.61",
769
- "resolved": "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.21.61.tgz",
770
- "integrity": "sha512-bAki2SyAkxYbzYCAcOmPYq++CzCHI1CkxeVQnF2jkXAh60wnIuR+jZJ+Eha1aaI049bB1aO8H+icr4C/kvLnuw==",
771
  "license": "MIT"
772
  },
773
  "node_modules/@huggingface/tokenizers": {
@@ -821,13 +821,13 @@
821
  "license": "MIT"
822
  },
823
  "node_modules/@mantine/carousel": {
824
- "version": "9.7.2",
825
- "resolved": "https://registry.npmjs.org/@mantine/carousel/-/carousel-9.7.2.tgz",
826
- "integrity": "sha512-qjBcEh2Hd1EenToNIc+vE5+C3/HFpKoAEcG0CP68QbsKyJA/R43NtXEoR+sodkz33E9FlZk7O8YJz6GvuLYrbQ==",
827
  "license": "MIT",
828
  "peerDependencies": {
829
- "@mantine/core": "9.7.2",
830
- "@mantine/hooks": "9.7.2",
831
  "embla-carousel": ">=8.0.0",
832
  "embla-carousel-react": ">=8.0.0",
833
  "react": "^19.2.0",
@@ -835,24 +835,24 @@
835
  }
836
  },
837
  "node_modules/@mantine/code-highlight": {
838
- "version": "9.7.2",
839
- "resolved": "https://registry.npmjs.org/@mantine/code-highlight/-/code-highlight-9.7.2.tgz",
840
- "integrity": "sha512-Vf1CGJw8QOT+roLOapSMnh/x4cdH/Qmd1kXLgkggbYuSIs5Io/6th5AbW7hFd1PXyBeZjvDos/rO+Ae85Aw6NQ==",
841
  "license": "MIT",
842
  "dependencies": {
843
  "clsx": "^2.1.1"
844
  },
845
  "peerDependencies": {
846
- "@mantine/core": "9.7.2",
847
- "@mantine/hooks": "9.7.2",
848
  "react": "^19.2.0",
849
  "react-dom": "^19.2.0"
850
  }
851
  },
852
  "node_modules/@mantine/core": {
853
- "version": "9.7.2",
854
- "resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.7.2.tgz",
855
- "integrity": "sha512-+cggJDD0PO057SwZU/dKlWPjp0LaAn1nEM9W1m/Ia4Cjb/Hy9DjZ4I7R7xirp/eZ6xnV7OqeGmPFSJ8zeGa5kQ==",
856
  "license": "MIT",
857
  "dependencies": {
858
  "@floating-ui/react": "^0.27.20",
@@ -862,15 +862,15 @@
862
  "type-fest": "^5.10.0"
863
  },
864
  "peerDependencies": {
865
- "@mantine/hooks": "9.7.2",
866
  "react": "^19.2.0",
867
  "react-dom": "^19.2.0"
868
  }
869
  },
870
  "node_modules/@mantine/form": {
871
- "version": "9.7.2",
872
- "resolved": "https://registry.npmjs.org/@mantine/form/-/form-9.7.2.tgz",
873
- "integrity": "sha512-+EskrnXLI0tzLzYh634pD3gcwZsaos5PjfxNW0Kpg7O15gugescdJnFcm4CCrNX57Saw/6fZn9IEqPx8/AcvRQ==",
874
  "license": "MIT",
875
  "dependencies": {
876
  "@standard-schema/spec": "^1.1.0",
@@ -882,34 +882,34 @@
882
  }
883
  },
884
  "node_modules/@mantine/hooks": {
885
- "version": "9.7.2",
886
- "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.7.2.tgz",
887
- "integrity": "sha512-11emsRp/Mg+vfaKVyoMwiMdCHmO/15XOL6JG9fACkclVZnLPBVvRIaDdRBudsR3ALGZ5iTzrArPJDjjoeO6X4w==",
888
  "license": "MIT",
889
  "peerDependencies": {
890
  "react": "^19.2.0"
891
  }
892
  },
893
  "node_modules/@mantine/notifications": {
894
- "version": "9.7.2",
895
- "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-9.7.2.tgz",
896
- "integrity": "sha512-Lf/oS2bpWgalgJ7Uy2Gh2V/kqnIIM3zkeWYy+GZKunfbiQGyuDBasOGd/4RYu5ga7b8vxJzC/zINi/UdpFuxoQ==",
897
  "license": "MIT",
898
  "dependencies": {
899
- "@mantine/store": "9.7.2",
900
  "react-transition-group": "4.4.5"
901
  },
902
  "peerDependencies": {
903
- "@mantine/core": "9.7.2",
904
- "@mantine/hooks": "9.7.2",
905
  "react": "^19.2.0",
906
  "react-dom": "^19.2.0"
907
  }
908
  },
909
  "node_modules/@mantine/store": {
910
- "version": "9.7.2",
911
- "resolved": "https://registry.npmjs.org/@mantine/store/-/store-9.7.2.tgz",
912
- "integrity": "sha512-ERjk46CbQeSqytWTq5cAzT4o9MBAzzvVXpPlD36B5NwB5hmbjQfdUKcr0PlZfG04a/PDPdLPvAiVp7Eeh0tk8A==",
913
  "license": "MIT",
914
  "peerDependencies": {
915
  "react": "^19.2.0"
@@ -1146,9 +1146,9 @@
1146
  }
1147
  },
1148
  "node_modules/@oxc-parser/binding-android-arm-eabi": {
1149
- "version": "0.153.0",
1150
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.153.0.tgz",
1151
- "integrity": "sha512-BcWu25dMwMjHpaDCEidmpAi7rGg1qZoihJeUUH9bD55pVZ0z4Wa5CQgqSTo0R41H81oozisQKW3WqvAN2m77IQ==",
1152
  "cpu": [
1153
  "arm"
1154
  ],
@@ -1163,9 +1163,9 @@
1163
  }
1164
  },
1165
  "node_modules/@oxc-parser/binding-android-arm64": {
1166
- "version": "0.153.0",
1167
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.153.0.tgz",
1168
- "integrity": "sha512-9l5rdp2DhRqVriBHsNpu5cEf+hizZ4B58rt93HzyQEBUgZiC6qe2oFpaAAnXGCxHGnQExI1s7S2G0QhFacUZ6w==",
1169
  "cpu": [
1170
  "arm64"
1171
  ],
@@ -1180,9 +1180,9 @@
1180
  }
1181
  },
1182
  "node_modules/@oxc-parser/binding-darwin-arm64": {
1183
- "version": "0.153.0",
1184
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.153.0.tgz",
1185
- "integrity": "sha512-EcpsyRXHhENDpekpG4QNaSZj2F/BAz4Gh4o89zZwNss86R8KyRSQ6sXbAj74Xyeb4z3j/yBZZ6joQZhraIWyfw==",
1186
  "cpu": [
1187
  "arm64"
1188
  ],
@@ -1197,9 +1197,9 @@
1197
  }
1198
  },
1199
  "node_modules/@oxc-parser/binding-darwin-x64": {
1200
- "version": "0.153.0",
1201
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.153.0.tgz",
1202
- "integrity": "sha512-hlj80HUQy07/2lYp6rmxH+3u+H2ik3rjLR52CUdPApxmZCNxASDUf/+ykTzAHS/fehftmVMATKvPaWmuLHuasg==",
1203
  "cpu": [
1204
  "x64"
1205
  ],
@@ -1214,9 +1214,9 @@
1214
  }
1215
  },
1216
  "node_modules/@oxc-parser/binding-freebsd-x64": {
1217
- "version": "0.153.0",
1218
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.153.0.tgz",
1219
- "integrity": "sha512-fWRcv2pMSh1qtez46MOu8vuUJ5fiaQZPHSphbWulbL7SHBMKfRgZl2PzjXZYQJbq7NEXyXbgIuIuUm+/Rmkv8w==",
1220
  "cpu": [
1221
  "x64"
1222
  ],
@@ -1231,9 +1231,9 @@
1231
  }
1232
  },
1233
  "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
1234
- "version": "0.153.0",
1235
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.153.0.tgz",
1236
- "integrity": "sha512-paZeUK1GXShl/bOJQET1/8AQxayFeuN4SIWAkgTRxE7ZQwv9uYSB2n0648smygP22N7jcI3zxAm0Z+ALNiIKxg==",
1237
  "cpu": [
1238
  "arm"
1239
  ],
@@ -1248,9 +1248,9 @@
1248
  }
1249
  },
1250
  "node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
1251
- "version": "0.153.0",
1252
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.153.0.tgz",
1253
- "integrity": "sha512-qFPp5NwMf4K1C0O+DCVGmcbOaYyYAq98Rvv9p4VSfb777iUwlJCqMUxf6QG9N5f5ls4TEX6LitOZHi+6GL6rYQ==",
1254
  "cpu": [
1255
  "arm"
1256
  ],
@@ -1265,9 +1265,9 @@
1265
  }
1266
  },
1267
  "node_modules/@oxc-parser/binding-linux-arm64-gnu": {
1268
- "version": "0.153.0",
1269
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.153.0.tgz",
1270
- "integrity": "sha512-oufEhZCX7saeo27Logra8cvBR1jQ/yw2zUI2hHmWw3qA/XYqEW31FOvLQBv7DWN32NjVdlcaP11WBBZTJo4k9g==",
1271
  "cpu": [
1272
  "arm64"
1273
  ],
@@ -1285,9 +1285,9 @@
1285
  }
1286
  },
1287
  "node_modules/@oxc-parser/binding-linux-arm64-musl": {
1288
- "version": "0.153.0",
1289
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.153.0.tgz",
1290
- "integrity": "sha512-zXYzuAqng85h6+LI3iRm0zeMLX1/K2z41ZAMWxVRgt0D10VvZUaV5q+TEB46KDhoKBpdexoTLC6QoB/EiP2oqQ==",
1291
  "cpu": [
1292
  "arm64"
1293
  ],
@@ -1305,9 +1305,9 @@
1305
  }
1306
  },
1307
  "node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
1308
- "version": "0.153.0",
1309
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.153.0.tgz",
1310
- "integrity": "sha512-J7+NLwLEzdkKM9cKyPHH/AjXXstWOs98KYgLrPPnO4N9MO8EZRBfPNnBcS3S67uMOBkRpUmCl2FhRnWdQ5CbBA==",
1311
  "cpu": [
1312
  "ppc64"
1313
  ],
@@ -1325,9 +1325,9 @@
1325
  }
1326
  },
1327
  "node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
1328
- "version": "0.153.0",
1329
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.153.0.tgz",
1330
- "integrity": "sha512-sD0K11GQTLWylln54nPMMTtgmBbQrR5It/vMpZpy/fogiw8O2AvIE+9LuWUXddrWk9zvFIHGRcSgFn4IHOfoYg==",
1331
  "cpu": [
1332
  "riscv64"
1333
  ],
@@ -1345,9 +1345,9 @@
1345
  }
1346
  },
1347
  "node_modules/@oxc-parser/binding-linux-riscv64-musl": {
1348
- "version": "0.153.0",
1349
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.153.0.tgz",
1350
- "integrity": "sha512-7L66uql2BhvSz51LwfExjOdn6et/ydTiQxl23VApdKzJhTc3TczA1Qt6VR/UcvlQrL2ZbytIJb447vh7p5wmUw==",
1351
  "cpu": [
1352
  "riscv64"
1353
  ],
@@ -1365,9 +1365,9 @@
1365
  }
1366
  },
1367
  "node_modules/@oxc-parser/binding-linux-s390x-gnu": {
1368
- "version": "0.153.0",
1369
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.153.0.tgz",
1370
- "integrity": "sha512-hXV3T7mNyrPR19rbPYkglkQJtoM/UNo/xwbZeeqdAxEg8PXgb4LXXuxPpGgI4bzRRCWwLUxdDSS58xIyC2u1TA==",
1371
  "cpu": [
1372
  "s390x"
1373
  ],
@@ -1385,9 +1385,9 @@
1385
  }
1386
  },
1387
  "node_modules/@oxc-parser/binding-linux-x64-gnu": {
1388
- "version": "0.153.0",
1389
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.153.0.tgz",
1390
- "integrity": "sha512-Da8Vqs1xdtoDYtEjmozD7wGgJeLBQkMbR0WUBECOSxodyX5DXy8ySlvJWbukyO2Q0+TM9EK+4LT6tbq0jCg4hQ==",
1391
  "cpu": [
1392
  "x64"
1393
  ],
@@ -1405,9 +1405,9 @@
1405
  }
1406
  },
1407
  "node_modules/@oxc-parser/binding-linux-x64-musl": {
1408
- "version": "0.153.0",
1409
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.153.0.tgz",
1410
- "integrity": "sha512-zGAPxNTIIw6X5WyTRscIFE/oDjpdNtrrl32ing6NOXdGjzRucadnxBilexLdeYQ9YxixF93GujpXyQ0jw7ddSg==",
1411
  "cpu": [
1412
  "x64"
1413
  ],
@@ -1425,9 +1425,9 @@
1425
  }
1426
  },
1427
  "node_modules/@oxc-parser/binding-openharmony-arm64": {
1428
- "version": "0.153.0",
1429
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.153.0.tgz",
1430
- "integrity": "sha512-GoHzpqP5XyPIp9DHokoEdCp+K4UPl/h1neFj1uXnUB3pJYyOQ7NOhFVOttNUgCGcj2eywAvu5cRivTOSq4S10g==",
1431
  "cpu": [
1432
  "arm64"
1433
  ],
@@ -1442,9 +1442,9 @@
1442
  }
1443
  },
1444
  "node_modules/@oxc-parser/binding-win32-arm64-msvc": {
1445
- "version": "0.153.0",
1446
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.153.0.tgz",
1447
- "integrity": "sha512-xGcy12tO9k7zFbeyQzEuilfWfQpyRq23/fLPfN3wjSWuWv58k46fQT1hsO1KFNykZuVGW9xSJE/z592TmAoSDA==",
1448
  "cpu": [
1449
  "arm64"
1450
  ],
@@ -1459,9 +1459,9 @@
1459
  }
1460
  },
1461
  "node_modules/@oxc-parser/binding-win32-ia32-msvc": {
1462
- "version": "0.153.0",
1463
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.153.0.tgz",
1464
- "integrity": "sha512-o1ykUjWncvXX6N5a4KebgLjtD8zRui+f1llMhSGoSC9oP2KOqapRpdwmZ3NcNG0axFouwVKmhGBjd+IhA8BksA==",
1465
  "cpu": [
1466
  "ia32"
1467
  ],
@@ -1476,9 +1476,9 @@
1476
  }
1477
  },
1478
  "node_modules/@oxc-parser/binding-win32-x64-msvc": {
1479
- "version": "0.153.0",
1480
- "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.153.0.tgz",
1481
- "integrity": "sha512-gxQz7G96Up136fd+2zWrP+o6FYGs9NO4Dyv55IoeJFJUDwLFHcLYY11hhA5wQKs0HoM57Q8esQ3nEVhnCdsjAQ==",
1482
  "cpu": [
1483
  "x64"
1484
  ],
@@ -1493,9 +1493,9 @@
1493
  }
1494
  },
1495
  "node_modules/@oxc-project/types": {
1496
- "version": "0.152.0",
1497
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
1498
- "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
1499
  "license": "MIT",
1500
  "funding": {
1501
  "url": "https://github.com/sponsors/oxc-project"
@@ -1869,9 +1869,9 @@
1869
  "license": "BSD-3-Clause"
1870
  },
1871
  "node_modules/@rolldown/binding-android-arm-eabi": {
1872
- "version": "1.2.12",
1873
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
1874
- "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
1875
  "cpu": [
1876
  "arm"
1877
  ],
@@ -1885,9 +1885,9 @@
1885
  }
1886
  },
1887
  "node_modules/@rolldown/binding-android-arm64": {
1888
- "version": "1.2.12",
1889
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
1890
- "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
1891
  "cpu": [
1892
  "arm64"
1893
  ],
@@ -1901,9 +1901,9 @@
1901
  }
1902
  },
1903
  "node_modules/@rolldown/binding-darwin-arm64": {
1904
- "version": "1.2.12",
1905
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
1906
- "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
1907
  "cpu": [
1908
  "arm64"
1909
  ],
@@ -1917,9 +1917,9 @@
1917
  }
1918
  },
1919
  "node_modules/@rolldown/binding-darwin-x64": {
1920
- "version": "1.2.12",
1921
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
1922
- "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
1923
  "cpu": [
1924
  "x64"
1925
  ],
@@ -1933,9 +1933,9 @@
1933
  }
1934
  },
1935
  "node_modules/@rolldown/binding-freebsd-x64": {
1936
- "version": "1.2.12",
1937
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
1938
- "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
1939
  "cpu": [
1940
  "x64"
1941
  ],
@@ -1949,9 +1949,9 @@
1949
  }
1950
  },
1951
  "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
1952
- "version": "1.2.12",
1953
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
1954
- "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
1955
  "cpu": [
1956
  "arm"
1957
  ],
@@ -1965,9 +1965,9 @@
1965
  }
1966
  },
1967
  "node_modules/@rolldown/binding-linux-arm64-gnu": {
1968
- "version": "1.2.12",
1969
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
1970
- "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
1971
  "cpu": [
1972
  "arm64"
1973
  ],
@@ -1984,9 +1984,9 @@
1984
  }
1985
  },
1986
  "node_modules/@rolldown/binding-linux-arm64-musl": {
1987
- "version": "1.2.12",
1988
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
1989
- "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
1990
  "cpu": [
1991
  "arm64"
1992
  ],
@@ -2003,9 +2003,9 @@
2003
  }
2004
  },
2005
  "node_modules/@rolldown/binding-linux-ppc64-gnu": {
2006
- "version": "1.2.12",
2007
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
2008
- "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
2009
  "cpu": [
2010
  "ppc64"
2011
  ],
@@ -2022,9 +2022,9 @@
2022
  }
2023
  },
2024
  "node_modules/@rolldown/binding-linux-s390x-gnu": {
2025
- "version": "1.2.12",
2026
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
2027
- "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
2028
  "cpu": [
2029
  "s390x"
2030
  ],
@@ -2041,9 +2041,9 @@
2041
  }
2042
  },
2043
  "node_modules/@rolldown/binding-linux-x64-gnu": {
2044
- "version": "1.2.12",
2045
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
2046
- "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
2047
  "cpu": [
2048
  "x64"
2049
  ],
@@ -2060,9 +2060,9 @@
2060
  }
2061
  },
2062
  "node_modules/@rolldown/binding-linux-x64-musl": {
2063
- "version": "1.2.12",
2064
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
2065
- "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
2066
  "cpu": [
2067
  "x64"
2068
  ],
@@ -2079,9 +2079,9 @@
2079
  }
2080
  },
2081
  "node_modules/@rolldown/binding-openharmony-arm64": {
2082
- "version": "1.2.12",
2083
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
2084
- "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
2085
  "cpu": [
2086
  "arm64"
2087
  ],
@@ -2095,9 +2095,9 @@
2095
  }
2096
  },
2097
  "node_modules/@rolldown/binding-win32-arm64-msvc": {
2098
- "version": "1.2.12",
2099
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
2100
- "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
2101
  "cpu": [
2102
  "arm64"
2103
  ],
@@ -2111,9 +2111,9 @@
2111
  }
2112
  },
2113
  "node_modules/@rolldown/binding-win32-x64-msvc": {
2114
- "version": "1.2.12",
2115
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
2116
- "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
2117
  "cpu": [
2118
  "x64"
2119
  ],
@@ -2267,9 +2267,9 @@
2267
  "license": "MIT"
2268
  },
2269
  "node_modules/@tabler/icons": {
2270
- "version": "3.49.0",
2271
- "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.49.0.tgz",
2272
- "integrity": "sha512-l+GnSkFETx0U26icxv1VUFnxxiTqXfCcVRiRAnyMwibupM2S2L/hSaZ9gmSgz01rkrsH/93NvLsxykqdanc6lw==",
2273
  "license": "MIT",
2274
  "funding": {
2275
  "type": "github",
@@ -2277,12 +2277,12 @@
2277
  }
2278
  },
2279
  "node_modules/@tabler/icons-react": {
2280
- "version": "3.49.0",
2281
- "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.49.0.tgz",
2282
- "integrity": "sha512-Um59pm5RVNUaQjFUtG0r4oYJbZuBS3tGugAA2tgBg0WufMk34Eio3nsO30RUkLuL2giYPQUNdY9HvqltLLYyNA==",
2283
  "license": "MIT",
2284
  "dependencies": {
2285
- "@tabler/icons": "3.49.0"
2286
  },
2287
  "funding": {
2288
  "type": "github",
@@ -2482,12 +2482,12 @@
2482
  "license": "MIT"
2483
  },
2484
  "node_modules/@types/node": {
2485
- "version": "24.19.2",
2486
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
2487
- "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
2488
  "license": "MIT",
2489
  "dependencies": {
2490
- "undici-types": ">=7.24.0 <7.24.7"
2491
  }
2492
  },
2493
  "node_modules/@types/react": {
@@ -3024,14 +3024,14 @@
3024
  }
3025
  },
3026
  "node_modules/ai": {
3027
- "version": "7.0.142",
3028
- "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.142.tgz",
3029
- "integrity": "sha512-Tifni6/6nNhCu4GnFEeYYR7CGmJ3Yhm44SGxYrOupqP2aaZwmGGiiB5Zx4hiauJR0AWimcXULejDT+Qsj+SHOQ==",
3030
  "license": "Apache-2.0",
3031
  "dependencies": {
3032
- "@ai-sdk/gateway": "4.0.115",
3033
- "@ai-sdk/provider": "4.0.28",
3034
- "@ai-sdk/provider-utils": "5.0.61"
3035
  },
3036
  "engines": {
3037
  "node": ">=22"
@@ -3040,18 +3040,6 @@
3040
  "zod": "^3.25.76 || ^4.1.8"
3041
  }
3042
  },
3043
- "node_modules/ai/node_modules/@ai-sdk/provider": {
3044
- "version": "4.0.28",
3045
- "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.28.tgz",
3046
- "integrity": "sha512-6ThcmkJQXSMlBrf6mirIjUUS04Q5/kApbYTOzd5lZfYXLa1XPpnDLxP/G96/DKc00fDIUOW9S2ls0mSzb0+l9w==",
3047
- "license": "Apache-2.0",
3048
- "dependencies": {
3049
- "json-schema": "^0.4.0"
3050
- },
3051
- "engines": {
3052
- "node": ">=22"
3053
- }
3054
- },
3055
  "node_modules/ansi-regex": {
3056
  "version": "5.0.1",
3057
  "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -3587,9 +3575,9 @@
3587
  }
3588
  },
3589
  "node_modules/dexie": {
3590
- "version": "4.4.6",
3591
- "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.6.tgz",
3592
- "integrity": "sha512-hJP/BO6mjB+tX6hToIO1kmxYLNmun90wYbfcoAoLpKEyDYal/k33dg0TAfoUe2TDsbbLoVIzljJ3BN2UbR5EOg==",
3593
  "license": "Apache-2.0"
3594
  },
3595
  "node_modules/dom-accessibility-api": {
@@ -3665,9 +3653,9 @@
3665
  }
3666
  },
3667
  "node_modules/dotenv": {
3668
- "version": "18.0.8",
3669
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.8.tgz",
3670
- "integrity": "sha512-2KDeoFoK/zRtcnbunCDlLG5VWVMcyqeQc6iK65o231nfNAcuGrKoqUQfJFUtBI+QlShk5HKIHxwebBtUl3rEfA==",
3671
  "license": "BSD-2-Clause",
3672
  "bin": {
3673
  "dotenv": "dist/index.cjs"
@@ -3838,9 +3826,9 @@
3838
  "license": "MIT"
3839
  },
3840
  "node_modules/fake-indexeddb": {
3841
- "version": "6.2.5",
3842
- "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
3843
- "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
3844
  "dev": true,
3845
  "license": "Apache-2.0",
3846
  "engines": {
@@ -4023,9 +4011,9 @@
4023
  }
4024
  },
4025
  "node_modules/gpt-tokenizer": {
4026
- "version": "4.0.3",
4027
- "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-4.0.3.tgz",
4028
- "integrity": "sha512-GrrPDreSy2c3ZgM/u+5JN+lnHTLrJKm7QAFfTD5J+vgcSP69C+fFIUaJWiey/QQ8lS5u79OvluKuEmGBAbOS4A==",
4029
  "license": "MIT"
4030
  },
4031
  "node_modules/graceful-fs": {
@@ -4458,9 +4446,9 @@
4458
  "license": "MIT"
4459
  },
4460
  "node_modules/jscpd": {
4461
- "version": "5.4.1",
4462
- "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-5.4.1.tgz",
4463
- "integrity": "sha512-afUt+LSQMmzSPYrWZlNGTwj8X4tmwVJbMNmr92qxPf4uUF/naLwG7SQyMPEZtrHqGgzGxmAzNjiwlVdjls2MyQ==",
4464
  "dev": true,
4465
  "license": "MIT",
4466
  "bin": {
@@ -4473,20 +4461,20 @@
4473
  "url": "https://opencollective.com/jscpd"
4474
  },
4475
  "optionalDependencies": {
4476
- "jscpd-darwin-arm64": "5.4.1",
4477
- "jscpd-darwin-x64": "5.4.1",
4478
- "jscpd-linux-arm64-gnu": "5.4.1",
4479
- "jscpd-linux-arm64-musl": "5.4.1",
4480
- "jscpd-linux-x64-gnu": "5.4.1",
4481
- "jscpd-linux-x64-musl": "5.4.1",
4482
- "jscpd-windows-arm64-msvc": "5.4.1",
4483
- "jscpd-windows-x64-msvc": "5.4.1"
4484
  }
4485
  },
4486
  "node_modules/jscpd-darwin-arm64": {
4487
- "version": "5.4.1",
4488
- "resolved": "https://registry.npmjs.org/jscpd-darwin-arm64/-/jscpd-darwin-arm64-5.4.1.tgz",
4489
- "integrity": "sha512-LXHVeH60C9BsozcVIMPmXWvj97KqFKbCJYmYG4hDwfwIwS1kz+uF7yiObckP6h2xo12pD88nI4XjWTB5frjZUw==",
4490
  "cpu": [
4491
  "arm64"
4492
  ],
@@ -4498,9 +4486,9 @@
4498
  ]
4499
  },
4500
  "node_modules/jscpd-darwin-x64": {
4501
- "version": "5.4.1",
4502
- "resolved": "https://registry.npmjs.org/jscpd-darwin-x64/-/jscpd-darwin-x64-5.4.1.tgz",
4503
- "integrity": "sha512-Gqck4tcMZjv4IvYHU2Vcikb885zTADo5T/YfEqeu1i5W5tfFpLdViiTYrh5wbrW+uxBqC33gHpG1hy0xbGYknQ==",
4504
  "cpu": [
4505
  "x64"
4506
  ],
@@ -4512,9 +4500,9 @@
4512
  ]
4513
  },
4514
  "node_modules/jscpd-linux-arm64-gnu": {
4515
- "version": "5.4.1",
4516
- "resolved": "https://registry.npmjs.org/jscpd-linux-arm64-gnu/-/jscpd-linux-arm64-gnu-5.4.1.tgz",
4517
- "integrity": "sha512-XUYZJZVxSY7JIBEoyOOMTgvJwmymkns4wG55tu6I/vRKXnS5QtaTESQfRLt6xj1+62aflAvr01FAhPAy+du6tA==",
4518
  "cpu": [
4519
  "arm64"
4520
  ],
@@ -4529,9 +4517,9 @@
4529
  ]
4530
  },
4531
  "node_modules/jscpd-linux-arm64-musl": {
4532
- "version": "5.4.1",
4533
- "resolved": "https://registry.npmjs.org/jscpd-linux-arm64-musl/-/jscpd-linux-arm64-musl-5.4.1.tgz",
4534
- "integrity": "sha512-h2sH8Bk++yBxWqq2QAy4fvPzXkw1vecDSyOmzLwPfnRQoQ2Q/EWxCNOrgJSb+SNMLWoHLLzfqx8m9wt2GG6a8w==",
4535
  "cpu": [
4536
  "arm64"
4537
  ],
@@ -4546,9 +4534,9 @@
4546
  ]
4547
  },
4548
  "node_modules/jscpd-linux-x64-gnu": {
4549
- "version": "5.4.1",
4550
- "resolved": "https://registry.npmjs.org/jscpd-linux-x64-gnu/-/jscpd-linux-x64-gnu-5.4.1.tgz",
4551
- "integrity": "sha512-UiC4YoGAVVLy6hPVz8A6cndo7W5br4FAO2hADnFeinhBPnXPu0QFh1q/s/8B3ldeQHQkpYd/l5cjqNBMrbm78w==",
4552
  "cpu": [
4553
  "x64"
4554
  ],
@@ -4563,9 +4551,9 @@
4563
  ]
4564
  },
4565
  "node_modules/jscpd-linux-x64-musl": {
4566
- "version": "5.4.1",
4567
- "resolved": "https://registry.npmjs.org/jscpd-linux-x64-musl/-/jscpd-linux-x64-musl-5.4.1.tgz",
4568
- "integrity": "sha512-toAafl5roqfFotPrDJNMY9noNVZ9dReBqcO/nT5pJ8+7aq+opbscaLUcV4+8jxTq5btUD6CiO72+Fu+TyUZv3Q==",
4569
  "cpu": [
4570
  "x64"
4571
  ],
@@ -4580,9 +4568,9 @@
4580
  ]
4581
  },
4582
  "node_modules/jscpd-windows-arm64-msvc": {
4583
- "version": "5.4.1",
4584
- "resolved": "https://registry.npmjs.org/jscpd-windows-arm64-msvc/-/jscpd-windows-arm64-msvc-5.4.1.tgz",
4585
- "integrity": "sha512-JLMga/NIwv/OPurPsXA2/d5OdWfdwrsBk2D4NhyKnrV/uNjo1BIi2zXztzDyTQVKMK/TEK6GPgLD143GG1fZ0A==",
4586
  "cpu": [
4587
  "arm64"
4588
  ],
@@ -4594,9 +4582,9 @@
4594
  ]
4595
  },
4596
  "node_modules/jscpd-windows-x64-msvc": {
4597
- "version": "5.4.1",
4598
- "resolved": "https://registry.npmjs.org/jscpd-windows-x64-msvc/-/jscpd-windows-x64-msvc-5.4.1.tgz",
4599
- "integrity": "sha512-sN57Y9YP3ijrqc1IsWDceOFHUwbukg9/mU2NSDHTryKKc5CUvWz+o9GId8uKTiJIhkKfR/Ce5GKkCepTwB6kaw==",
4600
  "cpu": [
4601
  "x64"
4602
  ],
@@ -4671,9 +4659,9 @@
4671
  }
4672
  },
4673
  "node_modules/knip": {
4674
- "version": "6.41.0",
4675
- "resolved": "https://registry.npmjs.org/knip/-/knip-6.41.0.tgz",
4676
- "integrity": "sha512-DrhQlIuV6xScd5Q+WTF1znd1w41QzxFGCtAHXCajsH/ndGMotHoNYbdXbuFtzW3mIOmniHtZ7zCtvnQkUmJtQQ==",
4677
  "dev": true,
4678
  "funding": [
4679
  {
@@ -4690,13 +4678,13 @@
4690
  "formatly": "^0.11.4",
4691
  "get-tsconfig": "4.14.3",
4692
  "jiti": "^2.7.0",
4693
- "oxc-parser": "^0.153.0",
4694
  "oxc-resolver": "11.24.2",
4695
  "picomatch": "^4.0.7",
4696
  "smol-toml": "^1.9.0",
4697
  "strip-json-comments": "5.0.3",
4698
  "tinyglobby": "^0.2.17",
4699
- "unbash": "^5.0.0",
4700
  "yaml": "^2.9.1",
4701
  "zod": "^4.6.5"
4702
  },
@@ -6092,15 +6080,15 @@
6092
  }
6093
  },
6094
  "node_modules/onnxruntime-common": {
6095
- "version": "1.30.0",
6096
- "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.30.0.tgz",
6097
- "integrity": "sha512-7fdVWjAID1dVhH/G8qK3APARunV4VkBFoCQAP7qp4Wkab0mrorvmc+sqiT+mKXOzDqdjN5j+/Z9nb4gzNPWcyA==",
6098
  "license": "MIT"
6099
  },
6100
  "node_modules/onnxruntime-node": {
6101
- "version": "1.30.0",
6102
- "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.30.0.tgz",
6103
- "integrity": "sha512-twhs1C2C/BFkz1yc5OY0KIU2GUq6DURO7hD4bx5Q2Qy3nAMJwRXW8xU3NVczE29VA9lolLOYepoD8fjTGOfIqw==",
6104
  "hasInstallScript": true,
6105
  "license": "MIT",
6106
  "os": [
@@ -6111,7 +6099,7 @@
6111
  "dependencies": {
6112
  "adm-zip": "^0.6.0",
6113
  "global-agent": "^4.1.3",
6114
- "onnxruntime-common": "1.30.0"
6115
  }
6116
  },
6117
  "node_modules/onnxruntime-web": {
@@ -6155,13 +6143,13 @@
6155
  }
6156
  },
6157
  "node_modules/oxc-parser": {
6158
- "version": "0.153.0",
6159
- "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.153.0.tgz",
6160
- "integrity": "sha512-cEnpyyV1OJRXv++SFNw0z8i+2o/cr88rVLaOYJafELDoHM5yHvCAbwGRLvxuany6SNLcbblSMUyZaGzH+quSYg==",
6161
  "dev": true,
6162
  "license": "MIT",
6163
  "dependencies": {
6164
- "@oxc-project/types": "^0.153.0"
6165
  },
6166
  "engines": {
6167
  "node": "^20.19.0 || >=22.12.0"
@@ -6170,31 +6158,31 @@
6170
  "url": "https://github.com/sponsors/oxc-project"
6171
  },
6172
  "optionalDependencies": {
6173
- "@oxc-parser/binding-android-arm-eabi": "0.153.0",
6174
- "@oxc-parser/binding-android-arm64": "0.153.0",
6175
- "@oxc-parser/binding-darwin-arm64": "0.153.0",
6176
- "@oxc-parser/binding-darwin-x64": "0.153.0",
6177
- "@oxc-parser/binding-freebsd-x64": "0.153.0",
6178
- "@oxc-parser/binding-linux-arm-gnueabihf": "0.153.0",
6179
- "@oxc-parser/binding-linux-arm-musleabihf": "0.153.0",
6180
- "@oxc-parser/binding-linux-arm64-gnu": "0.153.0",
6181
- "@oxc-parser/binding-linux-arm64-musl": "0.153.0",
6182
- "@oxc-parser/binding-linux-ppc64-gnu": "0.153.0",
6183
- "@oxc-parser/binding-linux-riscv64-gnu": "0.153.0",
6184
- "@oxc-parser/binding-linux-riscv64-musl": "0.153.0",
6185
- "@oxc-parser/binding-linux-s390x-gnu": "0.153.0",
6186
- "@oxc-parser/binding-linux-x64-gnu": "0.153.0",
6187
- "@oxc-parser/binding-linux-x64-musl": "0.153.0",
6188
- "@oxc-parser/binding-openharmony-arm64": "0.153.0",
6189
- "@oxc-parser/binding-win32-arm64-msvc": "0.153.0",
6190
- "@oxc-parser/binding-win32-ia32-msvc": "0.153.0",
6191
- "@oxc-parser/binding-win32-x64-msvc": "0.153.0"
6192
  }
6193
  },
6194
  "node_modules/oxc-parser/node_modules/@oxc-project/types": {
6195
- "version": "0.153.0",
6196
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
6197
- "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
6198
  "dev": true,
6199
  "license": "MIT",
6200
  "funding": {
@@ -6408,9 +6396,9 @@
6408
  }
6409
  },
6410
  "node_modules/postcss": {
6411
- "version": "8.5.30",
6412
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
6413
- "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==",
6414
  "funding": [
6415
  {
6416
  "type": "opencollective",
@@ -6730,9 +6718,9 @@
6730
  }
6731
  },
6732
  "node_modules/rate-limiter-flexible": {
6733
- "version": "11.2.2",
6734
- "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-11.2.2.tgz",
6735
- "integrity": "sha512-n6M+msT0OWdril3PgkAjatDME2BEDhf8nBx4lJj9pMFQVcN6ldXtZvTX3nZdFqqX5utQ7kCUzq7LfyfmA2wglw==",
6736
  "license": "ISC"
6737
  },
6738
  "node_modules/react": {
@@ -7052,12 +7040,12 @@
7052
  }
7053
  },
7054
  "node_modules/rolldown": {
7055
- "version": "1.2.12",
7056
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
7057
- "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
7058
  "license": "MIT",
7059
  "dependencies": {
7060
- "@oxc-project/types": "=0.152.0",
7061
  "@rolldown/pluginutils": "^1.0.0"
7062
  },
7063
  "bin": {
@@ -7067,21 +7055,21 @@
7067
  "node": "^20.19.0 || >=22.12.0"
7068
  },
7069
  "optionalDependencies": {
7070
- "@rolldown/binding-android-arm-eabi": "1.2.12",
7071
- "@rolldown/binding-android-arm64": "1.2.12",
7072
- "@rolldown/binding-darwin-arm64": "1.2.12",
7073
- "@rolldown/binding-darwin-x64": "1.2.12",
7074
- "@rolldown/binding-freebsd-x64": "1.2.12",
7075
- "@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
7076
- "@rolldown/binding-linux-arm64-gnu": "1.2.12",
7077
- "@rolldown/binding-linux-arm64-musl": "1.2.12",
7078
- "@rolldown/binding-linux-ppc64-gnu": "1.2.12",
7079
- "@rolldown/binding-linux-s390x-gnu": "1.2.12",
7080
- "@rolldown/binding-linux-x64-gnu": "1.2.12",
7081
- "@rolldown/binding-linux-x64-musl": "1.2.12",
7082
- "@rolldown/binding-openharmony-arm64": "1.2.12",
7083
- "@rolldown/binding-win32-arm64-msvc": "1.2.12",
7084
- "@rolldown/binding-win32-x64-msvc": "1.2.12"
7085
  }
7086
  },
7087
  "node_modules/rollup-plugin-visualizer": {
@@ -7651,9 +7639,9 @@
7651
  }
7652
  },
7653
  "node_modules/unbash": {
7654
- "version": "5.0.0",
7655
- "resolved": "https://registry.npmjs.org/unbash/-/unbash-5.0.0.tgz",
7656
- "integrity": "sha512-RYNm/ISFtflWpIcXTPHHLJZ0mqzInav+L9ke9PcL+hHsSf9+Fd/syPb614Mlqs/O3AYgjnvxDOFl0a+zVqi1/w==",
7657
  "dev": true,
7658
  "license": "ISC",
7659
  "engines": {
@@ -7661,9 +7649,9 @@
7661
  }
7662
  },
7663
  "node_modules/undici": {
7664
- "version": "8.12.1",
7665
- "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.1.tgz",
7666
- "integrity": "sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==",
7667
  "dev": true,
7668
  "license": "MIT",
7669
  "engines": {
@@ -7671,9 +7659,9 @@
7671
  }
7672
  },
7673
  "node_modules/undici-types": {
7674
- "version": "7.24.6",
7675
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
7676
- "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
7677
  "license": "MIT"
7678
  },
7679
  "node_modules/unicode-emoji-modifier-base": {
@@ -7859,15 +7847,15 @@
7859
  }
7860
  },
7861
  "node_modules/vite": {
7862
- "version": "8.3.4",
7863
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
7864
- "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
7865
  "license": "MIT",
7866
  "dependencies": {
7867
  "lightningcss": "^1.33.0",
7868
  "picomatch": "^4.0.7",
7869
- "postcss": "^8.5.29",
7870
- "rolldown": "~1.2.12",
7871
  "tinyglobby": "^0.2.17"
7872
  },
7873
  "bin": {
@@ -7884,7 +7872,7 @@
7884
  },
7885
  "peerDependencies": {
7886
  "@types/node": "^20.19.0 || >=22.12.0",
7887
- "@vitejs/devtools": "^0.7.1",
7888
  "esbuild": "^0.27.0 || ^0.28.0",
7889
  "jiti": ">=1.21.0",
7890
  "less": "^4.0.0",
@@ -8309,9 +8297,9 @@
8309
  }
8310
  },
8311
  "node_modules/yet-another-react-lightbox": {
8312
- "version": "3.32.2",
8313
- "resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.32.2.tgz",
8314
- "integrity": "sha512-F4HtHQfUNpvkj+AmECgWM4XRdCqMY5gXpKgOUx39+T+FyxLe8II4SK/pwMyYj2X54KH9lFSQeHYY1/GfYf3SdA==",
8315
  "license": "MIT",
8316
  "engines": {
8317
  "node": ">=14"
 
39
  "http-compression": "^1.0.20",
40
  "keyword-extractor": "^0.3.0",
41
  "node-emoji": "^2.1.3",
42
+ "onnxruntime-node": "1.31.1",
43
  "pdf-parse": "^2.0.0",
44
  "postcss": "^8.5.25",
45
  "postcss-preset-mantine": "^1.17.0",
 
69
  "@testing-library/react": "^16.0.0",
70
  "@testing-library/user-event": "^14.5.0",
71
  "@types/html-to-text": "^9.0.4",
72
+ "@types/node": "^26.0.0",
73
  "@types/react": "^19.0.0",
74
  "@types/react-dom": "^19.0.0",
75
  "@vitest/coverage-v8": "^5.0.0",
 
95
  "license": "MIT"
96
  },
97
  "node_modules/@ai-sdk/gateway": {
98
+ "version": "4.0.132",
99
+ "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.132.tgz",
100
+ "integrity": "sha512-ppStDEz96g+JEuxGiVTP29OKBjPyYZXRxs38wnq3427U2QbZtxe6TukrgSAAq0n4yaHvSvVAWJY9kBlW8XudYg==",
101
  "license": "Apache-2.0",
102
  "dependencies": {
103
+ "@ai-sdk/provider": "4.0.35",
104
+ "@ai-sdk/provider-utils": "5.0.72",
105
  "@vercel/oidc": "3.2.0"
106
  },
107
  "engines": {
 
111
  "zod": "^3.25.76 || ^4.1.8"
112
  }
113
  },
 
 
 
 
 
 
 
 
 
 
 
 
114
  "node_modules/@ai-sdk/openai-compatible": {
115
+ "version": "3.0.82",
116
+ "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-3.0.82.tgz",
117
+ "integrity": "sha512-wey0CbujWRkZbbrJjw/GJHyjXpt0v44PQKaTGTVGmigqnM057G7GQHDyZQQj9O0lICY5qJxzpn4wCSftT8BSQg==",
118
  "license": "Apache-2.0",
119
  "dependencies": {
120
+ "@ai-sdk/provider": "4.0.35",
121
+ "@ai-sdk/provider-utils": "5.0.72"
122
  },
123
  "engines": {
124
  "node": ">=22"
 
127
  "zod": "^3.25.76 || ^4.1.8"
128
  }
129
  },
 
 
 
 
 
 
 
 
 
 
 
 
130
  "node_modules/@ai-sdk/provider": {
131
+ "version": "4.0.35",
132
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.35.tgz",
133
+ "integrity": "sha512-yBz6fmLS8MSCIzy7NiHEUAq56ZLD4ZA1GR6bpXBxgbwUacP1La7LuwAr/xy9jSzthem4F8Xu2OrhvU/JrE9fYA==",
134
  "license": "Apache-2.0",
135
  "dependencies": {
136
  "json-schema": "^0.4.0"
 
140
  }
141
  },
142
  "node_modules/@ai-sdk/provider-utils": {
143
+ "version": "5.0.72",
144
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.72.tgz",
145
+ "integrity": "sha512-M6QAztI84jWumQSvKcGOL91DxhChsWxn3D3gYtV0/7E1TPQg9BmLdIerOolUC/jghQbMlQrHyzp0hqdIhUZ2Rw==",
146
  "license": "Apache-2.0",
147
  "dependencies": {
148
+ "@ai-sdk/provider": "4.0.35",
149
  "@standard-schema/spec": "^1.1.0",
150
  "@workflow/serde": "4.1.0",
151
  "eventsource-parser": "^3.0.8",
 
158
  "zod": "^3.25.76 || ^4.1.8"
159
  }
160
  },
 
 
 
 
 
 
 
 
 
 
 
 
161
  "node_modules/@ai-sdk/provider-utils/node_modules/undici": {
162
  "version": "7.30.1",
163
  "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
 
285
  }
286
  },
287
  "node_modules/@biomejs/biome": {
288
+ "version": "2.6.4",
289
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.6.4.tgz",
290
+ "integrity": "sha512-8sKKwaNW4ot22awZ3lBjwWLHnUFXgXnrBPYS2gNlBAXRbDC++7kqjK2H7dR/FgCDtPvO1xfKpgEhJkGxAKYkxA==",
291
  "dev": true,
292
  "license": "MIT OR Apache-2.0",
293
  "bin": {
 
301
  "url": "https://opencollective.com/biome"
302
  },
303
  "optionalDependencies": {
304
+ "@biomejs/cli-android-arm64": "2.6.4",
305
+ "@biomejs/cli-android-x64": "2.6.4",
306
+ "@biomejs/cli-darwin-arm64": "2.6.4",
307
+ "@biomejs/cli-darwin-x64": "2.6.4",
308
+ "@biomejs/cli-linux-arm64": "2.6.4",
309
+ "@biomejs/cli-linux-arm64-musl": "2.6.4",
310
+ "@biomejs/cli-linux-x64": "2.6.4",
311
+ "@biomejs/cli-linux-x64-musl": "2.6.4",
312
+ "@biomejs/cli-win32-arm64": "2.6.4",
313
+ "@biomejs/cli-win32-x64": "2.6.4"
314
+ }
315
+ },
316
+ "node_modules/@biomejs/cli-android-arm64": {
317
+ "version": "2.6.4",
318
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-android-arm64/-/cli-android-arm64-2.6.4.tgz",
319
+ "integrity": "sha512-jwrwigP4tBcHxzuV8izioX/BOUhJL/4e2rNSOE5nCjBkVC018LQAQUGiH+CNkFYvLtQ1dcZo5/xXERmaAGc7JQ==",
320
+ "cpu": [
321
+ "arm64"
322
+ ],
323
+ "dev": true,
324
+ "license": "MIT OR Apache-2.0",
325
+ "optional": true,
326
+ "os": [
327
+ "android"
328
+ ],
329
+ "engines": {
330
+ "node": ">=14.21.3"
331
+ }
332
+ },
333
+ "node_modules/@biomejs/cli-android-x64": {
334
+ "version": "2.6.4",
335
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-android-x64/-/cli-android-x64-2.6.4.tgz",
336
+ "integrity": "sha512-HdGInPJ4nZFwHxzvEtcp2nmSBtUyJrurmtIK43upFckuS3+ZqVM05HtsxQWnqGsuy/pxckIAjyKWlEKRlBXUyA==",
337
+ "cpu": [
338
+ "x64"
339
+ ],
340
+ "dev": true,
341
+ "license": "MIT OR Apache-2.0",
342
+ "optional": true,
343
+ "os": [
344
+ "android"
345
+ ],
346
+ "engines": {
347
+ "node": ">=14.21.3"
348
  }
349
  },
350
  "node_modules/@biomejs/cli-darwin-arm64": {
351
+ "version": "2.6.4",
352
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.6.4.tgz",
353
+ "integrity": "sha512-0VvcUz46wbg59Q4TZ59KkIjDJrxEyu4Eoe79+F4SChxoWe+W7AEyIp6F5r72C9+p6MsB6dIovnZt1D30lGM3XA==",
354
  "cpu": [
355
  "arm64"
356
  ],
 
365
  }
366
  },
367
  "node_modules/@biomejs/cli-darwin-x64": {
368
+ "version": "2.6.4",
369
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.6.4.tgz",
370
+ "integrity": "sha512-fxmvkF81srlS3DiMyfm+aG3J0eG+6/BjaxRyjy57KbLSafnq0PO+hHfVWmtH2ty0S7nm6yDXBRwySfFeaRJKRQ==",
371
  "cpu": [
372
  "x64"
373
  ],
 
382
  }
383
  },
384
  "node_modules/@biomejs/cli-linux-arm64": {
385
+ "version": "2.6.4",
386
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.6.4.tgz",
387
+ "integrity": "sha512-6+bK1Y/evmIUiJr7auertuMbiaYVicJhqA1bZyL47lCMWugv8l0gFQCqvR/gN1vZMHYm+HwoTKgeMxdNFCoQTg==",
388
  "cpu": [
389
  "arm64"
390
  ],
 
402
  }
403
  },
404
  "node_modules/@biomejs/cli-linux-arm64-musl": {
405
+ "version": "2.6.4",
406
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.6.4.tgz",
407
+ "integrity": "sha512-31ZaH/MHqPX+O+evRwRrCHQ8W5/9adY+/eMytGGWZnRx1T41QsWIF09+/WujynLBhWZUxNGS5CtsWLwgOFTAyg==",
408
  "cpu": [
409
  "arm64"
410
  ],
 
422
  }
423
  },
424
  "node_modules/@biomejs/cli-linux-x64": {
425
+ "version": "2.6.4",
426
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.6.4.tgz",
427
+ "integrity": "sha512-tbFZMdL6dP/ws4edXxYG9WhvaVa1ZDzL4G5DzahnWW7Psib9H+VTdyU4XXVxpJk5j/nYZroSbvSNyYlpW3yCIw==",
428
  "cpu": [
429
  "x64"
430
  ],
 
442
  }
443
  },
444
  "node_modules/@biomejs/cli-linux-x64-musl": {
445
+ "version": "2.6.4",
446
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.6.4.tgz",
447
+ "integrity": "sha512-bomSa2XdQAGGj6Cwdm4M13qE+lN9+AOlW8KtI4hdyVyRRF/Z71qPAi95T4wtdZK0QpBbkTW8hgbZZCwPi4xzIw==",
448
  "cpu": [
449
  "x64"
450
  ],
 
462
  }
463
  },
464
  "node_modules/@biomejs/cli-win32-arm64": {
465
+ "version": "2.6.4",
466
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.6.4.tgz",
467
+ "integrity": "sha512-eCsqt8Ma3eVap+oyJCDnJZH60SIU464B66n3y38EUTJdM1T/HfZol1FJC9vb+sHToX0ix8XzqPcwJnOzyAJz0g==",
468
  "cpu": [
469
  "arm64"
470
  ],
 
479
  }
480
  },
481
  "node_modules/@biomejs/cli-win32-x64": {
482
+ "version": "2.6.4",
483
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.6.4.tgz",
484
+ "integrity": "sha512-pYsMy5AThiHFOZAsngg8qN4PurM6nInKBhVX7sbj3eNpRQqo1Hb0MgCwPL7VqVMrmcEEMj9Ks3qbkallR65e8g==",
485
  "cpu": [
486
  "x64"
487
  ],
 
746
  "license": "MIT"
747
  },
748
  "node_modules/@huggingface/hub": {
749
+ "version": "2.17.10",
750
+ "resolved": "https://registry.npmjs.org/@huggingface/hub/-/hub-2.17.10.tgz",
751
+ "integrity": "sha512-+2W2KKBMEMmfgFEgaWknCytsCQlyQ9Xpghv9BN5qOqkjb0l5f6mVhXBNyAVOmt/Gjny4I3Cb1ZKaUv28+0+17A==",
752
  "license": "MIT",
753
  "dependencies": {
754
+ "@huggingface/tasks": "^0.22.1",
755
  "@huggingface/xetchunk-wasm": "^0.2.0"
756
  },
757
  "bin": {
 
765
  }
766
  },
767
  "node_modules/@huggingface/tasks": {
768
+ "version": "0.22.1",
769
+ "resolved": "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.22.1.tgz",
770
+ "integrity": "sha512-/lf63T90+kAEWFnIOVsBaMsd9A3hMRaJYNaNeVeD8LD0//lJu+KFG4Q50p3pkjdXU0e+gPwvgfN2yxFdNLJ0dg==",
771
  "license": "MIT"
772
  },
773
  "node_modules/@huggingface/tokenizers": {
 
821
  "license": "MIT"
822
  },
823
  "node_modules/@mantine/carousel": {
824
+ "version": "9.7.4",
825
+ "resolved": "https://registry.npmjs.org/@mantine/carousel/-/carousel-9.7.4.tgz",
826
+ "integrity": "sha512-UAEaIl5sQvqRB30nzPXgNFsCu8m+4I5wCZKemUBEtPxFECF03HEvekBWO8yMUFp6jyjJSEguqr/y5tJm2wgpUw==",
827
  "license": "MIT",
828
  "peerDependencies": {
829
+ "@mantine/core": "9.7.4",
830
+ "@mantine/hooks": "9.7.4",
831
  "embla-carousel": ">=8.0.0",
832
  "embla-carousel-react": ">=8.0.0",
833
  "react": "^19.2.0",
 
835
  }
836
  },
837
  "node_modules/@mantine/code-highlight": {
838
+ "version": "9.7.4",
839
+ "resolved": "https://registry.npmjs.org/@mantine/code-highlight/-/code-highlight-9.7.4.tgz",
840
+ "integrity": "sha512-YGmJI4IwR54z5MXTaRiw/BKpF2FJno2bRnYkelbrSB+ZFerqy2625AyOFv5Ey8LbT0ggr4aft7R/+SqWvcvE2w==",
841
  "license": "MIT",
842
  "dependencies": {
843
  "clsx": "^2.1.1"
844
  },
845
  "peerDependencies": {
846
+ "@mantine/core": "9.7.4",
847
+ "@mantine/hooks": "9.7.4",
848
  "react": "^19.2.0",
849
  "react-dom": "^19.2.0"
850
  }
851
  },
852
  "node_modules/@mantine/core": {
853
+ "version": "9.7.4",
854
+ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.7.4.tgz",
855
+ "integrity": "sha512-c7aTGELNn74CxG9i5/tq9BHGh0lKdKmGGz+9iDF+gR7Ipt4U1TMM286lAmLpRrKL2bR+UEaN4blZ8UJmOuGJ4Q==",
856
  "license": "MIT",
857
  "dependencies": {
858
  "@floating-ui/react": "^0.27.20",
 
862
  "type-fest": "^5.10.0"
863
  },
864
  "peerDependencies": {
865
+ "@mantine/hooks": "9.7.4",
866
  "react": "^19.2.0",
867
  "react-dom": "^19.2.0"
868
  }
869
  },
870
  "node_modules/@mantine/form": {
871
+ "version": "9.7.4",
872
+ "resolved": "https://registry.npmjs.org/@mantine/form/-/form-9.7.4.tgz",
873
+ "integrity": "sha512-p8cImC+VgKKKRc6HJGlFtGAKNbKyHtKyDIgNJeoqUBQ1beUfQlEh0I6lmTmWCzGlijVUE+GdNJWveNr0PQKRIw==",
874
  "license": "MIT",
875
  "dependencies": {
876
  "@standard-schema/spec": "^1.1.0",
 
882
  }
883
  },
884
  "node_modules/@mantine/hooks": {
885
+ "version": "9.7.4",
886
+ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.7.4.tgz",
887
+ "integrity": "sha512-j6Xf9Sc1fhhGEKXg6BZA2geyP/Czqvo6Rsi0IduOCWgwl/MAAJbKRjRc7xe2+9vePLdfqLZqRIAOY5rta88uNA==",
888
  "license": "MIT",
889
  "peerDependencies": {
890
  "react": "^19.2.0"
891
  }
892
  },
893
  "node_modules/@mantine/notifications": {
894
+ "version": "9.7.4",
895
+ "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-9.7.4.tgz",
896
+ "integrity": "sha512-LabOPnE/YtdpXHn49ZJjiJJBYnYXUMi6SFuzxje7XxpoxImZ+wdGh4R/+l89NnWTxKwGoQL51iUTL8PSgu2A1Q==",
897
  "license": "MIT",
898
  "dependencies": {
899
+ "@mantine/store": "9.7.4",
900
  "react-transition-group": "4.4.5"
901
  },
902
  "peerDependencies": {
903
+ "@mantine/core": "9.7.4",
904
+ "@mantine/hooks": "9.7.4",
905
  "react": "^19.2.0",
906
  "react-dom": "^19.2.0"
907
  }
908
  },
909
  "node_modules/@mantine/store": {
910
+ "version": "9.7.4",
911
+ "resolved": "https://registry.npmjs.org/@mantine/store/-/store-9.7.4.tgz",
912
+ "integrity": "sha512-tICEOzsaV2eiV4QAegdErRLtfd0da02DKqsEIzJLa+8T5dWDnBMCqlWxHfBMnaL7HBAmpx41koad0zUZPGw7pg==",
913
  "license": "MIT",
914
  "peerDependencies": {
915
  "react": "^19.2.0"
 
1146
  }
1147
  },
1148
  "node_modules/@oxc-parser/binding-android-arm-eabi": {
1149
+ "version": "0.155.0",
1150
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.155.0.tgz",
1151
+ "integrity": "sha512-A6xv7hDtnTm+xlZ7utn6rRQpRMJHRhVr3gjRGqlx0RUX84mNk0pAsy6YN/wWhuOTkaZhRrrzaLxf0NfbH77j2w==",
1152
  "cpu": [
1153
  "arm"
1154
  ],
 
1163
  }
1164
  },
1165
  "node_modules/@oxc-parser/binding-android-arm64": {
1166
+ "version": "0.155.0",
1167
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.155.0.tgz",
1168
+ "integrity": "sha512-T1wWwH9/JI5g7Z4kEQwDOwhToxdDhRyOTklH+uuNpJ16m3MZFmGl+l2rKc0NHQdbVRG2LTOBsNjpW3h1BWytPA==",
1169
  "cpu": [
1170
  "arm64"
1171
  ],
 
1180
  }
1181
  },
1182
  "node_modules/@oxc-parser/binding-darwin-arm64": {
1183
+ "version": "0.155.0",
1184
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.155.0.tgz",
1185
+ "integrity": "sha512-ZoRB3qJxJ8DWA41jHja+YeB0lLfjBy4T+Kr15vnN1k65X3zbJt3rPZftEwN4Ju4tmA4K/T9dWWD7Z8ewQLnrVA==",
1186
  "cpu": [
1187
  "arm64"
1188
  ],
 
1197
  }
1198
  },
1199
  "node_modules/@oxc-parser/binding-darwin-x64": {
1200
+ "version": "0.155.0",
1201
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.155.0.tgz",
1202
+ "integrity": "sha512-vxtL86BtM+IgrpejA86nbTk5j0HKTIMeuaJ1fQciw6p9RfavPtIPjI7gN34owap0yG9txXBaAWpGdxokNwdqIw==",
1203
  "cpu": [
1204
  "x64"
1205
  ],
 
1214
  }
1215
  },
1216
  "node_modules/@oxc-parser/binding-freebsd-x64": {
1217
+ "version": "0.155.0",
1218
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.155.0.tgz",
1219
+ "integrity": "sha512-tN6Ru5e0UVnWaIuiH8jEwnvxuxg7FvhWwSd3oe5oDhOD4ObsSr9/N6WfHLjilUWlPPj65ptwrSDH0Y7lzwTzmw==",
1220
  "cpu": [
1221
  "x64"
1222
  ],
 
1231
  }
1232
  },
1233
  "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
1234
+ "version": "0.155.0",
1235
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.155.0.tgz",
1236
+ "integrity": "sha512-h4ozA5n6L6lwolsz9iwunEhKWnIVbmFRots5QxEix0H6nJ5ICpuDa7PuShuhfpIDFe3+Qvyscioyiv+9g+4Syg==",
1237
  "cpu": [
1238
  "arm"
1239
  ],
 
1248
  }
1249
  },
1250
  "node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
1251
+ "version": "0.155.0",
1252
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.155.0.tgz",
1253
+ "integrity": "sha512-058/+LardpfuUlsKIvBGk/CYoakEVcMgqYx8GgyUZeOxFWTojYLlCc1Gb60/y21v3esDhHidgiY/Zbq9LpBhaQ==",
1254
  "cpu": [
1255
  "arm"
1256
  ],
 
1265
  }
1266
  },
1267
  "node_modules/@oxc-parser/binding-linux-arm64-gnu": {
1268
+ "version": "0.155.0",
1269
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.155.0.tgz",
1270
+ "integrity": "sha512-nrOt2Y3xIAzu+lSPzH0864ieO+iCCXgoo9qFlGANE8Xh0cQ+RP8Wstfp+/wXN2YCyb98cAGzX0VruKYw7it/jw==",
1271
  "cpu": [
1272
  "arm64"
1273
  ],
 
1285
  }
1286
  },
1287
  "node_modules/@oxc-parser/binding-linux-arm64-musl": {
1288
+ "version": "0.155.0",
1289
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.155.0.tgz",
1290
+ "integrity": "sha512-KYAayyN5hQbJPtGKtRxlyc8XYmyntOplyXSQzTDrmbz7l/GCrzQbSswvyJFBTH1LGtsZrbXOAfN52i/Kz6kLKg==",
1291
  "cpu": [
1292
  "arm64"
1293
  ],
 
1305
  }
1306
  },
1307
  "node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
1308
+ "version": "0.155.0",
1309
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.155.0.tgz",
1310
+ "integrity": "sha512-CSuSY5hfjtsBnHXydOj8jiNfS1qHbdPZuPz1YL1cSgK7VmNYP+QlZIbG0BBYYBgX/vYEgZwbG9mBdW4YairZ5w==",
1311
  "cpu": [
1312
  "ppc64"
1313
  ],
 
1325
  }
1326
  },
1327
  "node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
1328
+ "version": "0.155.0",
1329
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.155.0.tgz",
1330
+ "integrity": "sha512-7KCfOQm+Nz2N8trAXbQkal4TQ5756WVEmhm35y816YHvyFnEfVsICsiV5k0pEszNf/fRm4cm2i6i18SJ6/iCWg==",
1331
  "cpu": [
1332
  "riscv64"
1333
  ],
 
1345
  }
1346
  },
1347
  "node_modules/@oxc-parser/binding-linux-riscv64-musl": {
1348
+ "version": "0.155.0",
1349
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.155.0.tgz",
1350
+ "integrity": "sha512-JSlhgoK+/g1TGJI5NuADx2+6syhxnQNLCWLXyO5QLFyetomcr/vIxSdiNlmyAv963YXUzCuwAwRG6npZ5xyJ1A==",
1351
  "cpu": [
1352
  "riscv64"
1353
  ],
 
1365
  }
1366
  },
1367
  "node_modules/@oxc-parser/binding-linux-s390x-gnu": {
1368
+ "version": "0.155.0",
1369
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.155.0.tgz",
1370
+ "integrity": "sha512-WFIX+dPokWp64mi434Md3U3lJaTwNxAphvGjpltoyQjGUsdXavOIRrnp9dOS34Hd75HOAwDxAI+YwWOwqmJbtg==",
1371
  "cpu": [
1372
  "s390x"
1373
  ],
 
1385
  }
1386
  },
1387
  "node_modules/@oxc-parser/binding-linux-x64-gnu": {
1388
+ "version": "0.155.0",
1389
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.155.0.tgz",
1390
+ "integrity": "sha512-g6xSDC/JpbnVOz5uafMGyK3txBVRFMQTx8opv/yHXGxqV3ZMVl3bf7W8XrSnF68dmy3gM4dgReNryuE53+WkeQ==",
1391
  "cpu": [
1392
  "x64"
1393
  ],
 
1405
  }
1406
  },
1407
  "node_modules/@oxc-parser/binding-linux-x64-musl": {
1408
+ "version": "0.155.0",
1409
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.155.0.tgz",
1410
+ "integrity": "sha512-6/4xlxKlZJWrQ0uwDFPc0H9+xLu7068CKhX3vIDW3SbvP/lVgnVuT5v6dyugeWskWpuxqF/WJ4WKwhxjU3pG2w==",
1411
  "cpu": [
1412
  "x64"
1413
  ],
 
1425
  }
1426
  },
1427
  "node_modules/@oxc-parser/binding-openharmony-arm64": {
1428
+ "version": "0.155.0",
1429
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.155.0.tgz",
1430
+ "integrity": "sha512-tltklL0J0XjDxIRTBXWOBhvc2mitcmES45pIxhwDWfxRyqz7Vkeu0NekTZEIxGE4f/lXgq3vZcjjffteQOVorg==",
1431
  "cpu": [
1432
  "arm64"
1433
  ],
 
1442
  }
1443
  },
1444
  "node_modules/@oxc-parser/binding-win32-arm64-msvc": {
1445
+ "version": "0.155.0",
1446
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.155.0.tgz",
1447
+ "integrity": "sha512-alAb9TzjatUEsOeoncAziUnfdTDxfyswhalFZ9sC6plUgATIUx2NIZ0+bMn6aJ28AduS1OpG/OAisr/YISn4Ig==",
1448
  "cpu": [
1449
  "arm64"
1450
  ],
 
1459
  }
1460
  },
1461
  "node_modules/@oxc-parser/binding-win32-ia32-msvc": {
1462
+ "version": "0.155.0",
1463
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.155.0.tgz",
1464
+ "integrity": "sha512-yc5cbSfcGmRv/KpxDFuyk8adEBaWd/9HilWvBbQ4bFbus1V2PUh2mGB2Cfq9rqZTqLeiMT8x0skV+LKp9hOMgQ==",
1465
  "cpu": [
1466
  "ia32"
1467
  ],
 
1476
  }
1477
  },
1478
  "node_modules/@oxc-parser/binding-win32-x64-msvc": {
1479
+ "version": "0.155.0",
1480
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.155.0.tgz",
1481
+ "integrity": "sha512-8heq6jGq+rfpTDRDjoQiBHw0maQZf3IXkS99pdHQZPoTL5kyF5qyPS0RidUlSdnWDIqxXb5nux7GBD9cT+imUw==",
1482
  "cpu": [
1483
  "x64"
1484
  ],
 
1493
  }
1494
  },
1495
  "node_modules/@oxc-project/types": {
1496
+ "version": "0.154.0",
1497
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
1498
+ "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==",
1499
  "license": "MIT",
1500
  "funding": {
1501
  "url": "https://github.com/sponsors/oxc-project"
 
1869
  "license": "BSD-3-Clause"
1870
  },
1871
  "node_modules/@rolldown/binding-android-arm-eabi": {
1872
+ "version": "1.2.14",
1873
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz",
1874
+ "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==",
1875
  "cpu": [
1876
  "arm"
1877
  ],
 
1885
  }
1886
  },
1887
  "node_modules/@rolldown/binding-android-arm64": {
1888
+ "version": "1.2.14",
1889
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz",
1890
+ "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==",
1891
  "cpu": [
1892
  "arm64"
1893
  ],
 
1901
  }
1902
  },
1903
  "node_modules/@rolldown/binding-darwin-arm64": {
1904
+ "version": "1.2.14",
1905
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz",
1906
+ "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==",
1907
  "cpu": [
1908
  "arm64"
1909
  ],
 
1917
  }
1918
  },
1919
  "node_modules/@rolldown/binding-darwin-x64": {
1920
+ "version": "1.2.14",
1921
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz",
1922
+ "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==",
1923
  "cpu": [
1924
  "x64"
1925
  ],
 
1933
  }
1934
  },
1935
  "node_modules/@rolldown/binding-freebsd-x64": {
1936
+ "version": "1.2.14",
1937
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz",
1938
+ "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==",
1939
  "cpu": [
1940
  "x64"
1941
  ],
 
1949
  }
1950
  },
1951
  "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
1952
+ "version": "1.2.14",
1953
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz",
1954
+ "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==",
1955
  "cpu": [
1956
  "arm"
1957
  ],
 
1965
  }
1966
  },
1967
  "node_modules/@rolldown/binding-linux-arm64-gnu": {
1968
+ "version": "1.2.14",
1969
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz",
1970
+ "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==",
1971
  "cpu": [
1972
  "arm64"
1973
  ],
 
1984
  }
1985
  },
1986
  "node_modules/@rolldown/binding-linux-arm64-musl": {
1987
+ "version": "1.2.14",
1988
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz",
1989
+ "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==",
1990
  "cpu": [
1991
  "arm64"
1992
  ],
 
2003
  }
2004
  },
2005
  "node_modules/@rolldown/binding-linux-ppc64-gnu": {
2006
+ "version": "1.2.14",
2007
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz",
2008
+ "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==",
2009
  "cpu": [
2010
  "ppc64"
2011
  ],
 
2022
  }
2023
  },
2024
  "node_modules/@rolldown/binding-linux-s390x-gnu": {
2025
+ "version": "1.2.14",
2026
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz",
2027
+ "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==",
2028
  "cpu": [
2029
  "s390x"
2030
  ],
 
2041
  }
2042
  },
2043
  "node_modules/@rolldown/binding-linux-x64-gnu": {
2044
+ "version": "1.2.14",
2045
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz",
2046
+ "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==",
2047
  "cpu": [
2048
  "x64"
2049
  ],
 
2060
  }
2061
  },
2062
  "node_modules/@rolldown/binding-linux-x64-musl": {
2063
+ "version": "1.2.14",
2064
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz",
2065
+ "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==",
2066
  "cpu": [
2067
  "x64"
2068
  ],
 
2079
  }
2080
  },
2081
  "node_modules/@rolldown/binding-openharmony-arm64": {
2082
+ "version": "1.2.14",
2083
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz",
2084
+ "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==",
2085
  "cpu": [
2086
  "arm64"
2087
  ],
 
2095
  }
2096
  },
2097
  "node_modules/@rolldown/binding-win32-arm64-msvc": {
2098
+ "version": "1.2.14",
2099
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz",
2100
+ "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==",
2101
  "cpu": [
2102
  "arm64"
2103
  ],
 
2111
  }
2112
  },
2113
  "node_modules/@rolldown/binding-win32-x64-msvc": {
2114
+ "version": "1.2.14",
2115
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz",
2116
+ "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==",
2117
  "cpu": [
2118
  "x64"
2119
  ],
 
2267
  "license": "MIT"
2268
  },
2269
  "node_modules/@tabler/icons": {
2270
+ "version": "3.50.0",
2271
+ "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.50.0.tgz",
2272
+ "integrity": "sha512-HflLxyrH1cqC7s7jzQGSjpsTAVIoaf7+/yHjbdM59rJQBMjliibp+LYhDPFxOMcwa1M8DNpOk6ENzQiWn9a1Dg==",
2273
  "license": "MIT",
2274
  "funding": {
2275
  "type": "github",
 
2277
  }
2278
  },
2279
  "node_modules/@tabler/icons-react": {
2280
+ "version": "3.50.0",
2281
+ "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.50.0.tgz",
2282
+ "integrity": "sha512-X9XhFbxAmSNimQ71VAd7opfd4z84x1pLrEbBT33VCIEsFs8zWVqdhh5r/Jyi/3iJjQ861kE5Wm9ixiJfS2nwBA==",
2283
  "license": "MIT",
2284
  "dependencies": {
2285
+ "@tabler/icons": "3.50.0"
2286
  },
2287
  "funding": {
2288
  "type": "github",
 
2482
  "license": "MIT"
2483
  },
2484
  "node_modules/@types/node": {
2485
+ "version": "26.6.6",
2486
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
2487
+ "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
2488
  "license": "MIT",
2489
  "dependencies": {
2490
+ "undici-types": "~8.9.0"
2491
  }
2492
  },
2493
  "node_modules/@types/react": {
 
3024
  }
3025
  },
3026
  "node_modules/ai": {
3027
+ "version": "7.0.159",
3028
+ "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.159.tgz",
3029
+ "integrity": "sha512-4e/BwDoKJlMNYZaqERcTbGte+4TwroN9qKnCprog/XVfD9InzVPlhCviDhN452JIRrRsYg9BT0WsQ2C3Ds2mVQ==",
3030
  "license": "Apache-2.0",
3031
  "dependencies": {
3032
+ "@ai-sdk/gateway": "4.0.132",
3033
+ "@ai-sdk/provider": "4.0.35",
3034
+ "@ai-sdk/provider-utils": "5.0.72"
3035
  },
3036
  "engines": {
3037
  "node": ">=22"
 
3040
  "zod": "^3.25.76 || ^4.1.8"
3041
  }
3042
  },
 
 
 
 
 
 
 
 
 
 
 
 
3043
  "node_modules/ansi-regex": {
3044
  "version": "5.0.1",
3045
  "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
 
3575
  }
3576
  },
3577
  "node_modules/dexie": {
3578
+ "version": "4.4.7",
3579
+ "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.7.tgz",
3580
+ "integrity": "sha512-wemFiCl3DTCnfzyqepUTX1ugbYbkTrw3aEZOG7YjAOUoEQB5g5PrEywPzk5JpbX/SxqQ9IvjUpxCDIEjoOWEfA==",
3581
  "license": "Apache-2.0"
3582
  },
3583
  "node_modules/dom-accessibility-api": {
 
3653
  }
3654
  },
3655
  "node_modules/dotenv": {
3656
+ "version": "18.0.9",
3657
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.9.tgz",
3658
+ "integrity": "sha512-3fVOW4cB02R066PUSwn8O4TJyz9NckQl7F81blIbaq9jKBm2tSmOKky7ZHIXzGvkVPXCSn6jkpziBPZdGFs8Tw==",
3659
  "license": "BSD-2-Clause",
3660
  "bin": {
3661
  "dotenv": "dist/index.cjs"
 
3826
  "license": "MIT"
3827
  },
3828
  "node_modules/fake-indexeddb": {
3829
+ "version": "6.2.7",
3830
+ "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.7.tgz",
3831
+ "integrity": "sha512-g0ghQ+BmU3CSZaHup5FigEAAtCmtNyxkrmID7OAeK2qONHghw1aQ2l2T1RxmfhP1H1tEXVRWArUTGVCk5832ww==",
3832
  "dev": true,
3833
  "license": "Apache-2.0",
3834
  "engines": {
 
4011
  }
4012
  },
4013
  "node_modules/gpt-tokenizer": {
4014
+ "version": "4.1.0",
4015
+ "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-4.1.0.tgz",
4016
+ "integrity": "sha512-r7Op7S3iPy9vGpcfHmfIlLN/+/mNL2kXDy3N/ppp2xC2pm57pyYTt3fqsncMZ/yfIT+d3mTGhd7H6fcF/qiqWg==",
4017
  "license": "MIT"
4018
  },
4019
  "node_modules/graceful-fs": {
 
4446
  "license": "MIT"
4447
  },
4448
  "node_modules/jscpd": {
4449
+ "version": "5.4.2",
4450
+ "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-5.4.2.tgz",
4451
+ "integrity": "sha512-bxZ1ObA4NyWBcM7Zh+QoGrdcMovbCxrDofFaLo2Lwgk/Axe/e45VYu8EM+mpPx2CpU/QGCoktONRnaRZl8Yq2w==",
4452
  "dev": true,
4453
  "license": "MIT",
4454
  "bin": {
 
4461
  "url": "https://opencollective.com/jscpd"
4462
  },
4463
  "optionalDependencies": {
4464
+ "jscpd-darwin-arm64": "5.4.2",
4465
+ "jscpd-darwin-x64": "5.4.2",
4466
+ "jscpd-linux-arm64-gnu": "5.4.2",
4467
+ "jscpd-linux-arm64-musl": "5.4.2",
4468
+ "jscpd-linux-x64-gnu": "5.4.2",
4469
+ "jscpd-linux-x64-musl": "5.4.2",
4470
+ "jscpd-windows-arm64-msvc": "5.4.2",
4471
+ "jscpd-windows-x64-msvc": "5.4.2"
4472
  }
4473
  },
4474
  "node_modules/jscpd-darwin-arm64": {
4475
+ "version": "5.4.2",
4476
+ "resolved": "https://registry.npmjs.org/jscpd-darwin-arm64/-/jscpd-darwin-arm64-5.4.2.tgz",
4477
+ "integrity": "sha512-EGoPYQZqESW2kJfxKZrUYsYWhzq2Irk6sBQQWZ7VPAGj2wwk3n8HtDP2FnJOiHWZtSz55I8zQWldzIrD55B5Vw==",
4478
  "cpu": [
4479
  "arm64"
4480
  ],
 
4486
  ]
4487
  },
4488
  "node_modules/jscpd-darwin-x64": {
4489
+ "version": "5.4.2",
4490
+ "resolved": "https://registry.npmjs.org/jscpd-darwin-x64/-/jscpd-darwin-x64-5.4.2.tgz",
4491
+ "integrity": "sha512-/bz/rgoahqeK0p5r+KjeTTS59d7bZpMxD7hQ4CaQ0qKZWBZIyu7hRdExzJ7/KrCEJ5cmRogvXX1jhFqldYLNXw==",
4492
  "cpu": [
4493
  "x64"
4494
  ],
 
4500
  ]
4501
  },
4502
  "node_modules/jscpd-linux-arm64-gnu": {
4503
+ "version": "5.4.2",
4504
+ "resolved": "https://registry.npmjs.org/jscpd-linux-arm64-gnu/-/jscpd-linux-arm64-gnu-5.4.2.tgz",
4505
+ "integrity": "sha512-YW/eX+mOYZmT/A3QEARYmJQoVQNV/cfecKCobNXZcD3mE57drpKHmJnvuL9uS85z0cUKWGCM57JVkVz6Y9oxxw==",
4506
  "cpu": [
4507
  "arm64"
4508
  ],
 
4517
  ]
4518
  },
4519
  "node_modules/jscpd-linux-arm64-musl": {
4520
+ "version": "5.4.2",
4521
+ "resolved": "https://registry.npmjs.org/jscpd-linux-arm64-musl/-/jscpd-linux-arm64-musl-5.4.2.tgz",
4522
+ "integrity": "sha512-jOLTO8YVRz65NaeYNeXoIf13yOPWhbM61xKXpv51DXQ2BXce6XfgPaJVeDCOM5s+8mt6aDtq0BZ8zptNeqGuIw==",
4523
  "cpu": [
4524
  "arm64"
4525
  ],
 
4534
  ]
4535
  },
4536
  "node_modules/jscpd-linux-x64-gnu": {
4537
+ "version": "5.4.2",
4538
+ "resolved": "https://registry.npmjs.org/jscpd-linux-x64-gnu/-/jscpd-linux-x64-gnu-5.4.2.tgz",
4539
+ "integrity": "sha512-eAmKLxx+pIOEX4lZ342CCCjL5bvf+a3gdmaaVHXiGIx2Zr3zKkF32YkfuN2sCvCui5I/wnely80XsRvVVFO47Q==",
4540
  "cpu": [
4541
  "x64"
4542
  ],
 
4551
  ]
4552
  },
4553
  "node_modules/jscpd-linux-x64-musl": {
4554
+ "version": "5.4.2",
4555
+ "resolved": "https://registry.npmjs.org/jscpd-linux-x64-musl/-/jscpd-linux-x64-musl-5.4.2.tgz",
4556
+ "integrity": "sha512-ers9YFscD6DrmhyjBDXo/L+yBKcclHdWTFLMulYVGLcoGRKfBFhmK08MI3ajN/GMmCpJu7rkDYKL3egdfE8OIw==",
4557
  "cpu": [
4558
  "x64"
4559
  ],
 
4568
  ]
4569
  },
4570
  "node_modules/jscpd-windows-arm64-msvc": {
4571
+ "version": "5.4.2",
4572
+ "resolved": "https://registry.npmjs.org/jscpd-windows-arm64-msvc/-/jscpd-windows-arm64-msvc-5.4.2.tgz",
4573
+ "integrity": "sha512-kOhVw4cVhN8T9jr3OVThB3pPpbff0ITHTwpJ/+4EGzMkbf0y6QtPpHDzRgiHJj7u1FSTBXTI6VhPhDUhT/os1g==",
4574
  "cpu": [
4575
  "arm64"
4576
  ],
 
4582
  ]
4583
  },
4584
  "node_modules/jscpd-windows-x64-msvc": {
4585
+ "version": "5.4.2",
4586
+ "resolved": "https://registry.npmjs.org/jscpd-windows-x64-msvc/-/jscpd-windows-x64-msvc-5.4.2.tgz",
4587
+ "integrity": "sha512-BuR5DCV7F/G5ukMEIzCrz43ea5C0t0ALcP7v+opOWefoi4aiQYCEmtoRwfXMM/zjOmNYmvJBDDfeH05U9uuzEg==",
4588
  "cpu": [
4589
  "x64"
4590
  ],
 
4659
  }
4660
  },
4661
  "node_modules/knip": {
4662
+ "version": "6.44.0",
4663
+ "resolved": "https://registry.npmjs.org/knip/-/knip-6.44.0.tgz",
4664
+ "integrity": "sha512-mT0fl71Mqrko91azUO2Jw/uhYKwldORKT4liRq6yV0ZAtt6G2eYusdnvx8eBSQJDjvhRGKkpKUUx+vUG6syV4w==",
4665
  "dev": true,
4666
  "funding": [
4667
  {
 
4678
  "formatly": "^0.11.4",
4679
  "get-tsconfig": "4.14.3",
4680
  "jiti": "^2.7.0",
4681
+ "oxc-parser": "^0.155.0",
4682
  "oxc-resolver": "11.24.2",
4683
  "picomatch": "^4.0.7",
4684
  "smol-toml": "^1.9.0",
4685
  "strip-json-comments": "5.0.3",
4686
  "tinyglobby": "^0.2.17",
4687
+ "unbash": "^6.0.1",
4688
  "yaml": "^2.9.1",
4689
  "zod": "^4.6.5"
4690
  },
 
6080
  }
6081
  },
6082
  "node_modules/onnxruntime-common": {
6083
+ "version": "1.31.1",
6084
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.31.1.tgz",
6085
+ "integrity": "sha512-F+nnYNiQtmNJ95V6R/isBJqiVpdANCrsMEg1OaF+Bkbl3GAtzWLR7Ng17Ld0QtYtYJyotOHPJzOQn1Y6PLZjpg==",
6086
  "license": "MIT"
6087
  },
6088
  "node_modules/onnxruntime-node": {
6089
+ "version": "1.31.1",
6090
+ "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.31.1.tgz",
6091
+ "integrity": "sha512-ijnSghbDHVWaQ2nDPmVccPlCHI8kH1Mx1aJ4a/qzRP16bBcVs4E3EqZQKZCkhWnbqN1tuQWxagimFtDpbPYYQw==",
6092
  "hasInstallScript": true,
6093
  "license": "MIT",
6094
  "os": [
 
6099
  "dependencies": {
6100
  "adm-zip": "^0.6.0",
6101
  "global-agent": "^4.1.3",
6102
+ "onnxruntime-common": "1.31.1"
6103
  }
6104
  },
6105
  "node_modules/onnxruntime-web": {
 
6143
  }
6144
  },
6145
  "node_modules/oxc-parser": {
6146
+ "version": "0.155.0",
6147
+ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.155.0.tgz",
6148
+ "integrity": "sha512-C/ReSf1JUFukK/zvk6jhcNibSssvas5+ktIzK7fbKVd3CedWe59C3QwHEOrEZj/4Rt9+xg0Jeis/Jv1F/KM1mw==",
6149
  "dev": true,
6150
  "license": "MIT",
6151
  "dependencies": {
6152
+ "@oxc-project/types": "^0.155.0"
6153
  },
6154
  "engines": {
6155
  "node": "^20.19.0 || >=22.12.0"
 
6158
  "url": "https://github.com/sponsors/oxc-project"
6159
  },
6160
  "optionalDependencies": {
6161
+ "@oxc-parser/binding-android-arm-eabi": "0.155.0",
6162
+ "@oxc-parser/binding-android-arm64": "0.155.0",
6163
+ "@oxc-parser/binding-darwin-arm64": "0.155.0",
6164
+ "@oxc-parser/binding-darwin-x64": "0.155.0",
6165
+ "@oxc-parser/binding-freebsd-x64": "0.155.0",
6166
+ "@oxc-parser/binding-linux-arm-gnueabihf": "0.155.0",
6167
+ "@oxc-parser/binding-linux-arm-musleabihf": "0.155.0",
6168
+ "@oxc-parser/binding-linux-arm64-gnu": "0.155.0",
6169
+ "@oxc-parser/binding-linux-arm64-musl": "0.155.0",
6170
+ "@oxc-parser/binding-linux-ppc64-gnu": "0.155.0",
6171
+ "@oxc-parser/binding-linux-riscv64-gnu": "0.155.0",
6172
+ "@oxc-parser/binding-linux-riscv64-musl": "0.155.0",
6173
+ "@oxc-parser/binding-linux-s390x-gnu": "0.155.0",
6174
+ "@oxc-parser/binding-linux-x64-gnu": "0.155.0",
6175
+ "@oxc-parser/binding-linux-x64-musl": "0.155.0",
6176
+ "@oxc-parser/binding-openharmony-arm64": "0.155.0",
6177
+ "@oxc-parser/binding-win32-arm64-msvc": "0.155.0",
6178
+ "@oxc-parser/binding-win32-ia32-msvc": "0.155.0",
6179
+ "@oxc-parser/binding-win32-x64-msvc": "0.155.0"
6180
  }
6181
  },
6182
  "node_modules/oxc-parser/node_modules/@oxc-project/types": {
6183
+ "version": "0.155.0",
6184
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
6185
+ "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
6186
  "dev": true,
6187
  "license": "MIT",
6188
  "funding": {
 
6396
  }
6397
  },
6398
  "node_modules/postcss": {
6399
+ "version": "8.5.32",
6400
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
6401
+ "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
6402
  "funding": [
6403
  {
6404
  "type": "opencollective",
 
6718
  }
6719
  },
6720
  "node_modules/rate-limiter-flexible": {
6721
+ "version": "11.2.3",
6722
+ "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-11.2.3.tgz",
6723
+ "integrity": "sha512-w4HX4toAsEbu1VksOauZwI9d6URb6/4WqJJ5Hg1bESihMt2KJfQ2iTwvDkhIg+y2HetLh65DGu/8xIkUz/xNPA==",
6724
  "license": "ISC"
6725
  },
6726
  "node_modules/react": {
 
7040
  }
7041
  },
7042
  "node_modules/rolldown": {
7043
+ "version": "1.2.14",
7044
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
7045
+ "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==",
7046
  "license": "MIT",
7047
  "dependencies": {
7048
+ "@oxc-project/types": "=0.154.0",
7049
  "@rolldown/pluginutils": "^1.0.0"
7050
  },
7051
  "bin": {
 
7055
  "node": "^20.19.0 || >=22.12.0"
7056
  },
7057
  "optionalDependencies": {
7058
+ "@rolldown/binding-android-arm-eabi": "1.2.14",
7059
+ "@rolldown/binding-android-arm64": "1.2.14",
7060
+ "@rolldown/binding-darwin-arm64": "1.2.14",
7061
+ "@rolldown/binding-darwin-x64": "1.2.14",
7062
+ "@rolldown/binding-freebsd-x64": "1.2.14",
7063
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.14",
7064
+ "@rolldown/binding-linux-arm64-gnu": "1.2.14",
7065
+ "@rolldown/binding-linux-arm64-musl": "1.2.14",
7066
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.14",
7067
+ "@rolldown/binding-linux-s390x-gnu": "1.2.14",
7068
+ "@rolldown/binding-linux-x64-gnu": "1.2.14",
7069
+ "@rolldown/binding-linux-x64-musl": "1.2.14",
7070
+ "@rolldown/binding-openharmony-arm64": "1.2.14",
7071
+ "@rolldown/binding-win32-arm64-msvc": "1.2.14",
7072
+ "@rolldown/binding-win32-x64-msvc": "1.2.14"
7073
  }
7074
  },
7075
  "node_modules/rollup-plugin-visualizer": {
 
7639
  }
7640
  },
7641
  "node_modules/unbash": {
7642
+ "version": "6.0.1",
7643
+ "resolved": "https://registry.npmjs.org/unbash/-/unbash-6.0.1.tgz",
7644
+ "integrity": "sha512-0db9eS5iqybux41zLkX69nk90KTci7F7eZ+gXzEG/Q7E0tAqEBhRh5fkZvk1N7jT7QkGWqq34i8SFGPFEe1V8A==",
7645
  "dev": true,
7646
  "license": "ISC",
7647
  "engines": {
 
7649
  }
7650
  },
7651
  "node_modules/undici": {
7652
+ "version": "8.12.3",
7653
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz",
7654
+ "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==",
7655
  "dev": true,
7656
  "license": "MIT",
7657
  "engines": {
 
7659
  }
7660
  },
7661
  "node_modules/undici-types": {
7662
+ "version": "8.9.0",
7663
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
7664
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
7665
  "license": "MIT"
7666
  },
7667
  "node_modules/unicode-emoji-modifier-base": {
 
7847
  }
7848
  },
7849
  "node_modules/vite": {
7850
+ "version": "8.3.5",
7851
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
7852
+ "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
7853
  "license": "MIT",
7854
  "dependencies": {
7855
  "lightningcss": "^1.33.0",
7856
  "picomatch": "^4.0.7",
7857
+ "postcss": "^8.5.30",
7858
+ "rolldown": "~1.2.14",
7859
  "tinyglobby": "^0.2.17"
7860
  },
7861
  "bin": {
 
7872
  },
7873
  "peerDependencies": {
7874
  "@types/node": "^20.19.0 || >=22.12.0",
7875
+ "@vitejs/devtools": "^0.7.1 || ^0.8.0",
7876
  "esbuild": "^0.27.0 || ^0.28.0",
7877
  "jiti": ">=1.21.0",
7878
  "less": "^4.0.0",
 
8297
  }
8298
  },
8299
  "node_modules/yet-another-react-lightbox": {
8300
+ "version": "3.33.0",
8301
+ "resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.33.0.tgz",
8302
+ "integrity": "sha512-RxN9wjx6W8mgNf7scD1XBnieXqp8EUHZUPZf+DiALNcQb2JcVkVBH7l03e7jPmTx0xiMcf4N1qh/QBbQAG2kRg==",
8303
  "license": "MIT",
8304
  "engines": {
8305
  "node": ">=14"
package.json CHANGED
@@ -32,7 +32,8 @@
32
  "eval:answer": "vitest run --config vitest.eval.config.ts answer",
33
  "changelog-guard": "node scripts/changelog-guard.cjs",
34
  "license-check": "node scripts/license-check.cjs",
35
- "native-module-check": "node scripts/native-module-integrity.cjs"
 
36
  },
37
  "allowScripts": {
38
  "onnxruntime-node": false,
@@ -70,7 +71,7 @@
70
  "http-compression": "^1.0.20",
71
  "keyword-extractor": "^0.3.0",
72
  "node-emoji": "^2.1.3",
73
- "onnxruntime-node": "1.30.0",
74
  "pdf-parse": "^2.0.0",
75
  "postcss": "^8.5.25",
76
  "postcss-preset-mantine": "^1.17.0",
@@ -100,7 +101,7 @@
100
  "@testing-library/react": "^16.0.0",
101
  "@testing-library/user-event": "^14.5.0",
102
  "@types/html-to-text": "^9.0.4",
103
- "@types/node": "^24.0.0",
104
  "@types/react": "^19.0.0",
105
  "@types/react-dom": "^19.0.0",
106
  "@vitest/coverage-v8": "^5.0.0",
 
32
  "eval:answer": "vitest run --config vitest.eval.config.ts answer",
33
  "changelog-guard": "node scripts/changelog-guard.cjs",
34
  "license-check": "node scripts/license-check.cjs",
35
+ "native-module-check": "node scripts/native-module-integrity.cjs",
36
+ "wllama-catalog-check": "node scripts/wllama-catalog-check.cjs"
37
  },
38
  "allowScripts": {
39
  "onnxruntime-node": false,
 
71
  "http-compression": "^1.0.20",
72
  "keyword-extractor": "^0.3.0",
73
  "node-emoji": "^2.1.3",
74
+ "onnxruntime-node": "1.31.1",
75
  "pdf-parse": "^2.0.0",
76
  "postcss": "^8.5.25",
77
  "postcss-preset-mantine": "^1.17.0",
 
101
  "@testing-library/react": "^16.0.0",
102
  "@testing-library/user-event": "^14.5.0",
103
  "@types/html-to-text": "^9.0.4",
104
+ "@types/node": "^26.0.0",
105
  "@types/react": "^19.0.0",
106
  "@types/react-dom": "^19.0.0",
107
  "@vitest/coverage-v8": "^5.0.0",