github-actions[bot] commited on
Commit
6c3af4e
ยท
0 Parent(s):

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

Browse files
This view is limited to 50 files because it contains too many changes. ย  See raw diff
Files changed (50) hide show
  1. .dockerignore +47 -0
  2. .editorconfig +7 -0
  3. .env.example +27 -0
  4. .github/CODE_OF_CONDUCT.md +84 -0
  5. .github/CONTRIBUTING.md +55 -0
  6. .github/ISSUE_TEMPLATE/bug_report.yml +123 -0
  7. .github/ISSUE_TEMPLATE/config.yml +10 -0
  8. .github/ISSUE_TEMPLATE/feature_request.yml +73 -0
  9. .github/ISSUE_TEMPLATE/security_vulnerability.yml +95 -0
  10. .github/PULL_REQUEST_TEMPLATE.md +8 -0
  11. .github/SECURITY.md +132 -0
  12. .github/SUPPORT.md +36 -0
  13. .github/hf-space-config.yml +11 -0
  14. .github/workflows/ci.yml +47 -0
  15. .github/workflows/deploy-to-hugging-face.yml +24 -0
  16. .github/workflows/on-pull-request-to-main.yml +11 -0
  17. .github/workflows/on-push-to-main.yml +9 -0
  18. .github/workflows/publish-docker-image.yml +50 -0
  19. .github/workflows/reusable-check-docker.yml +48 -0
  20. .github/workflows/scan-docker-image.yml +33 -0
  21. .github/workflows/security.yml +24 -0
  22. .github/workflows/stale.yml +38 -0
  23. .gitignore +16 -0
  24. .hadolint.yaml +10 -0
  25. .husky/pre-commit +21 -0
  26. .npmrc +8 -0
  27. .nvmrc +1 -0
  28. Dockerfile +74 -0
  29. README.md +179 -0
  30. agents.md +257 -0
  31. biome.json +34 -0
  32. changelog.md +765 -0
  33. client/components/AiResponse/AiModelDownloadAllowanceContent.tsx +62 -0
  34. client/components/AiResponse/AiResponseContent.test.tsx +72 -0
  35. client/components/AiResponse/AiResponseContent.tsx +174 -0
  36. client/components/AiResponse/AiResponseSection.tsx +112 -0
  37. client/components/AiResponse/ChatHeader.tsx +33 -0
  38. client/components/AiResponse/ChatInputArea.tsx +112 -0
  39. client/components/AiResponse/ChatInterface.test.tsx +651 -0
  40. client/components/AiResponse/ChatInterface.tsx +430 -0
  41. client/components/AiResponse/CopyIconButton.tsx +32 -0
  42. client/components/AiResponse/EnableAiResponsePrompt.test.tsx +32 -0
  43. client/components/AiResponse/EnableAiResponsePrompt.tsx +85 -0
  44. client/components/AiResponse/ExpandableLink.tsx +123 -0
  45. client/components/AiResponse/FormattedMarkdown.tsx +41 -0
  46. client/components/AiResponse/LoadingModelContent.test.tsx +31 -0
  47. client/components/AiResponse/LoadingModelContent.tsx +43 -0
  48. client/components/AiResponse/MarkdownRenderer.tsx +104 -0
  49. client/components/AiResponse/MessageList.tsx +128 -0
  50. client/components/AiResponse/PreparingContent.tsx +33 -0
.dockerignore ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment files (may contain secrets)
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+
6
+ # Logs
7
+ logs
8
+ *.log
9
+ npm-debug.log*
10
+ yarn-debug.log*
11
+ yarn-error.log*
12
+ pnpm-debug.log*
13
+ lerna-debug.log*
14
+
15
+ # Dependencies and build output. Vite writes to `client/dist`, so these need
16
+ # `**/` for the same reason as the test patterns below.
17
+ node_modules
18
+ **/dist
19
+ **/dist-ssr
20
+ *.local
21
+
22
+ # Test files (not needed at runtime). The `**/` matters: a leading `*` does not
23
+ # cross `/`, so a bare `*.test.ts` would only match the context root.
24
+ **/*.test.ts
25
+ **/*.test.tsx
26
+ **/__tests__
27
+
28
+ # Documentation and CI (not needed at runtime)
29
+ docs
30
+ .github
31
+
32
+ # Coverage and lint reports
33
+ coverage
34
+ vite-build-stats.html
35
+ knip
36
+ jscpd-report
37
+
38
+ # Editor directories and files
39
+ .vscode/*
40
+ !.vscode/extensions.json
41
+ .idea
42
+ .DS_Store
43
+ *.suo
44
+ *.ntvs*
45
+ *.njsproj
46
+ *.sln
47
+ *.sw?
.editorconfig ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ [*]
2
+ charset = utf-8
3
+ insert_final_newline = true
4
+ end_of_line = lf
5
+ indent_style = space
6
+ indent_size = 2
7
+ max_line_length = 80
.env.example ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A comma-separated list of access keys. Example: `ACCESS_KEYS="ABC123,JUD71F,HUWE3"`. Leave blank for unrestricted access.
2
+ ACCESS_KEYS=""
3
+
4
+ # The timeout in hours for access key validation. Set to 0 to require validation on every page load.
5
+ ACCESS_KEY_TIMEOUT_HOURS="24"
6
+
7
+ # The default model ID for Wllama.
8
+ WLLAMA_DEFAULT_MODEL_ID="littlelamb-290m"
9
+
10
+ # The base URL for the internal OpenAI compatible API. Example: `INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL="https://api.openai.com/v1"`. Leave blank to disable internal OpenAI compatible API.
11
+ INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL=""
12
+
13
+ # The access key for the internal OpenAI compatible API.
14
+ INTERNAL_OPENAI_COMPATIBLE_API_KEY=""
15
+
16
+ # The model for the internal OpenAI compatible API.
17
+ INTERNAL_OPENAI_COMPATIBLE_API_MODEL=""
18
+
19
+ # The name of the internal OpenAI compatible API, displayed in the UI.
20
+ INTERNAL_OPENAI_COMPATIBLE_API_NAME="Internal API"
21
+
22
+ # The type of inference to use by default. The possible values are:
23
+ # "browser" -> In the browser
24
+ # "openai" -> Remote Server (OpenAI-compatible API)
25
+ # "horde" -> AI Horde
26
+ # "internal" -> $INTERNAL_OPENAI_COMPATIBLE_API_NAME
27
+ DEFAULT_INFERENCE_TYPE="browser"
.github/CODE_OF_CONDUCT.md ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributor Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6
+
7
+ We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8
+
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
+
29
+ Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
30
+
31
+ Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
32
+
33
+ ## Scope
34
+
35
+ This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
36
+
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
+
44
+ All complaints will be reviewed and investigated promptly and fairly.
45
+
46
+ All community leaders are obligated to respect the privacy and security of the reporter of any incident.
47
+
48
+ ## Enforcement Guidelines
49
+
50
+ Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
51
+
52
+ ### 1. Correction
53
+
54
+ **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
55
+
56
+ **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
57
+
58
+ ### 2. Warning
59
+
60
+ **Community Impact**: A violation through a single incident or series of actions.
61
+
62
+ **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
63
+
64
+ ### 3. Temporary Ban
65
+
66
+ **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
67
+
68
+ **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
69
+
70
+ ### 4. Permanent Ban
71
+
72
+ **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
73
+
74
+ **Consequence**: A permanent ban from any sort of public interaction within the community.
75
+
76
+ ## Attribution
77
+
78
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
79
+
80
+ Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
81
+
82
+ [homepage]: https://www.contributor-covenant.org
83
+
84
+ For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
.github/CONTRIBUTING.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing to MiniSearch
2
+
3
+ First off, thank you for considering contributing to MiniSearch! It's people like you that make MiniSearch such a great tool.
4
+
5
+ ## Your First Contribution
6
+
7
+ Not sure where to start? [Browse issues labeled "good first issue"](https://github.com/felladrin/MiniSearch/issues?q=label%3A%22good+first+issue%22+state%3Aopen). They are curated for first-time contributors, and you are welcome to ask for help in the issue comments.
8
+
9
+ ## Quick Start
10
+
11
+ - **Onboarding**: See [`../docs/quick-start.md`](../docs/quick-start.md) for setup and running the app.
12
+ - **Development workflow**: See [`../docs/pull-requests.md`](../docs/pull-requests.md) for the full PR process.
13
+ - **Architecture guide**: See [`../agents.md`](../agents.md) for codebase navigation.
14
+ - **Full docs**: Browse [`../docs/`](../docs/) for architecture, configuration, and AI integration.
15
+
16
+ ## Code Quality
17
+
18
+ Before submitting a pull request, please run:
19
+
20
+ ```bash
21
+ 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)
29
+ - jscpd (copy-paste detection)
30
+ - Custom architectural linter
31
+ - `doc-gardening.cjs` (documentation checks)
32
+ - `documentation-validator.cjs` (documentation validation)
33
+
34
+ ## Reporting Bugs
35
+
36
+ - Use the [GitHub issue tracker](https://github.com/felladrin/MiniSearch/issues) to report bugs.
37
+ - Check if the issue has already been reported before creating a new one.
38
+ - Provide clear steps to reproduce the issue.
39
+ - Include your environment details (OS, browser, Docker version if applicable).
40
+ - Add screenshots if the issue is UI-related.
41
+
42
+ ## Suggesting Features
43
+
44
+ - Use the [GitHub issue tracker](https://github.com/felladrin/MiniSearch/issues) for feature suggestions.
45
+ - Clearly describe the feature and why it would be useful.
46
+ - Consider if it fits with the project's minimalist philosophy.
47
+ - Provide examples of how you envision the feature working.
48
+
49
+ ## Questions?
50
+
51
+ Don't hesitate to ask questions! We're here to help you contribute successfully.
52
+
53
+ ---
54
+
55
+ Thanks again for your interest in contributing to MiniSearch! ๐ŸŽ‰
.github/ISSUE_TEMPLATE/bug_report.yml ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Bug report
3
+ description: Create a report to help us improve
4
+ title: "[BUG] "
5
+ labels: ["bug"]
6
+ assignees: []
7
+ body:
8
+ - type: markdown
9
+ attributes:
10
+ value: |
11
+ Thanks for taking the time to fill out this bug report! Please provide as much detail as possible.
12
+
13
+ - type: textarea
14
+ id: bug-description
15
+ attributes:
16
+ label: Describe the bug
17
+ description: A clear and concise description of what the bug is
18
+ placeholder: What happened? What did you expect to happen?
19
+ validations:
20
+ required: true
21
+
22
+ - type: textarea
23
+ id: steps-to-reproduce
24
+ attributes:
25
+ label: Steps to reproduce
26
+ description: Please provide detailed steps to reproduce the issue
27
+ placeholder: |
28
+ 1. Go to '...'
29
+ 2. Click on '....'
30
+ 3. Scroll down to '....'
31
+ 4. See error
32
+ validations:
33
+ required: true
34
+
35
+ - type: textarea
36
+ id: expected-behavior
37
+ attributes:
38
+ label: Expected behavior
39
+ description: A clear and concise description of what you expected to happen
40
+ validations:
41
+ required: true
42
+
43
+ - type: textarea
44
+ id: screenshots
45
+ attributes:
46
+ label: Screenshots
47
+ description: If applicable, add screenshots to help explain your problem
48
+ placeholder: Drag and drop images here or paste them
49
+
50
+ - type: dropdown
51
+ id: os
52
+ attributes:
53
+ label: Operating System
54
+ description: What operating system are you using?
55
+ options:
56
+ - Windows 11
57
+ - Windows 10
58
+ - macOS 15.x
59
+ - macOS 14.x
60
+ - macOS 13.x
61
+ - Ubuntu 22.04
62
+ - Ubuntu 20.04
63
+ - Other Linux
64
+ - Other
65
+ validations:
66
+ required: true
67
+
68
+ - type: dropdown
69
+ id: browser
70
+ attributes:
71
+ label: Browser
72
+ description: What browser are you using?
73
+ options:
74
+ - Chrome
75
+ - Firefox
76
+ - Safari
77
+ - Edge
78
+ - Other
79
+ validations:
80
+ required: true
81
+
82
+ - type: input
83
+ id: version
84
+ attributes:
85
+ label: MiniSearch version
86
+ description: What version of MiniSearch are you using?
87
+ placeholder: latest, v1.0.0, etc.
88
+ validations:
89
+ required: true
90
+
91
+ - type: input
92
+ id: docker-version
93
+ attributes:
94
+ label: Docker version (if applicable)
95
+ description: What Docker version are you using?
96
+ placeholder: e.g., 24.0.7
97
+
98
+ - type: textarea
99
+ id: additional-context
100
+ attributes:
101
+ label: Additional context
102
+ description: Add any other context about the problem here
103
+
104
+ - type: dropdown
105
+ id: deployment-type
106
+ attributes:
107
+ label: Deployment type
108
+ description: How are you running MiniSearch?
109
+ options:
110
+ - Docker image
111
+ - Building from source
112
+ - Development server
113
+ - Other
114
+ validations:
115
+ required: true
116
+
117
+ - type: textarea
118
+ id: custom-config
119
+ attributes:
120
+ label: Custom configuration (if applicable)
121
+ description: Any custom configuration you're using
122
+ placeholder: Environment variables, custom settings, etc.
123
+ ---
.github/ISSUE_TEMPLATE/config.yml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ contact_links:
2
+ - name: MiniSearch Discussions
3
+ url: https://github.com/felladrin/MiniSearch/discussions
4
+ about: Ask questions, share ideas, and discuss features
5
+ - name: Report a Security Vulnerability Privately
6
+ url: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability
7
+ about: Report security vulnerabilities privately via GitHub's Private Vulnerability Reporting
8
+ - name: Live Demo
9
+ url: https://felladrin-minisearch.hf.space
10
+ about: Try MiniSearch in your browser
.github/ISSUE_TEMPLATE/feature_request.yml ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Feature request
3
+ description: Suggest an idea for this project
4
+ title: "[FEATURE] "
5
+ labels: ["enhancement"]
6
+ assignees: []
7
+ body:
8
+ - type: markdown
9
+ attributes:
10
+ value: |
11
+ Thanks for suggesting a new feature! Please provide as much detail as possible to help us understand your vision.
12
+
13
+ - type: textarea
14
+ id: problem-description
15
+ attributes:
16
+ label: Is your feature request related to a problem?
17
+ description: A clear and concise description of what the problem is
18
+ placeholder: I'm always frustrated when [...]
19
+ validations:
20
+ required: true
21
+
22
+ - type: textarea
23
+ id: solution-description
24
+ attributes:
25
+ label: Describe the solution you'd like
26
+ description: A clear and concise description of what you want to happen
27
+ validations:
28
+ required: true
29
+
30
+ - type: textarea
31
+ id: alternatives
32
+ attributes:
33
+ label: Describe alternatives you've considered
34
+ description: A clear and concise description of any alternative solutions or features you've considered
35
+
36
+ - type: textarea
37
+ id: additional-context
38
+ attributes:
39
+ label: Additional context
40
+ description: Add any other context or screenshots about the feature request here
41
+
42
+ - type: dropdown
43
+ id: alignment
44
+ attributes:
45
+ label: Alignment with MiniSearch's goals
46
+ description: How does this feature align with MiniSearch's minimalist, privacy-focused philosophy?
47
+ options:
48
+ - "Strongly aligns - enhances privacy/minimalism"
49
+ - "Moderately aligns - useful but adds complexity"
50
+ - "Needs consideration - potential trade-offs"
51
+ - "Not sure - need discussion"
52
+ validations:
53
+ required: true
54
+
55
+ - type: textarea
56
+ id: implementation-ideas
57
+ attributes:
58
+ label: Implementation ideas (optional)
59
+ description: Do you have any ideas about how this could be implemented?
60
+ placeholder: Technical thoughts, potential approaches, etc.
61
+
62
+ - type: dropdown
63
+ id: priority
64
+ attributes:
65
+ label: Priority (your assessment)
66
+ description: How important is this feature to you?
67
+ options:
68
+ - "High - would significantly improve my experience"
69
+ - "Medium - nice to have"
70
+ - "Low - minor improvement"
71
+ validations:
72
+ required: true
73
+ ---
.github/ISSUE_TEMPLATE/security_vulnerability.yml ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Security Vulnerability
3
+ description: Report a security vulnerability (PRIVATE - will be converted to private report)
4
+ title: "[SECURITY] "
5
+ labels: ["security"]
6
+ assignees: []
7
+ body:
8
+ - type: markdown
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.
16
+
17
+ - type: checkboxes
18
+ id: confirmation
19
+ attributes:
20
+ label: Confirmation
21
+ description: Please confirm you understand the security reporting process
22
+ options:
23
+ - label: I understand this should be reported privately and will use the method above
24
+ required: true
25
+
26
+ - type: dropdown
27
+ id: vulnerability-type
28
+ attributes:
29
+ label: Vulnerability Type
30
+ description: What type of security vulnerability is this?
31
+ options:
32
+ - Cross-site Scripting (XSS)
33
+ - Authentication/Authorization Bypass
34
+ - Information Disclosure
35
+ - Remote Code Execution
36
+ - Denial of Service
37
+ - Configuration Issue
38
+ - Other
39
+ validations:
40
+ required: true
41
+
42
+ - type: dropdown
43
+ id: severity
44
+ attributes:
45
+ label: Severity
46
+ description: How severe is this vulnerability?
47
+ options:
48
+ - Critical
49
+ - High
50
+ - Medium
51
+ - Low
52
+ validations:
53
+ required: true
54
+
55
+ - type: textarea
56
+ id: description
57
+ attributes:
58
+ label: Description
59
+ description: A clear description of the security vulnerability
60
+ validations:
61
+ required: true
62
+
63
+ - type: textarea
64
+ id: steps-to-reproduce
65
+ attributes:
66
+ label: Steps to Reproduce
67
+ description: Detailed steps to reproduce the vulnerability
68
+ validations:
69
+ required: true
70
+
71
+ - type: textarea
72
+ id: impact
73
+ attributes:
74
+ label: Impact
75
+ description: Describe the potential impact of this vulnerability
76
+ validations:
77
+ required: true
78
+
79
+ - type: textarea
80
+ id: mitigation
81
+ attributes:
82
+ label: Mitigation (if known)
83
+ description: Any known workarounds or mitigations
84
+
85
+ - type: textarea
86
+ id: additional-information
87
+ attributes:
88
+ label: Additional Information
89
+ description: Any additional context or information about the vulnerability
90
+
91
+ - type: markdown
92
+ attributes:
93
+ value: |
94
+ ## ๐Ÿšจ PLEASE DO NOT SUBMIT THIS PUBLICLY - USE PRIVATE REPORTING METHODS ABOVE ๐Ÿšจ
95
+ ---
.github/PULL_REQUEST_TEMPLATE.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
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. -->
.github/SECURITY.md ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
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
+
14
+ ### Private Vulnerability Reporting
15
+
16
+ Report security vulnerabilities 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) feature. It is the only reporting channel, so every report stays private and tracked in one place.
17
+
18
+ **Do not report security vulnerabilities through public issues.**
19
+
20
+ ### What to Include
21
+
22
+ When reporting a vulnerability, please include:
23
+ - A clear description of the vulnerability
24
+ - Steps to reproduce the issue
25
+ - Potential impact of the vulnerability
26
+ - Any suggested mitigations (if known)
27
+
28
+ ### What to Expect
29
+
30
+ - We will acknowledge receipt of your report within 48 hours
31
+ - We will provide a detailed response within 7 days
32
+ - We will work with you to understand and validate the report
33
+ - We will coordinate disclosure timing to minimize user risk
34
+
35
+ ## Security Scope
36
+
37
+ ### In Scope
38
+
39
+ - Vulnerabilities in the MiniSearch web application
40
+ - Security issues in the Docker container configuration
41
+ - Authentication and authorization bypasses
42
+ - Cross-site scripting (XSS) vulnerabilities
43
+ - Information disclosure issues
44
+ - Remote code execution vulnerabilities
45
+ - Privilege escalation in the application context
46
+
47
+ ### Out of Scope
48
+
49
+ - Issues in third-party dependencies (report to respective projects)
50
+ - Vulnerabilities in the underlying browser or Node.js runtime
51
+ - Physical attacks on infrastructure
52
+ - Social engineering attacks
53
+ - Denial of service attacks that don't indicate a vulnerability
54
+ - Issues requiring physical access to user devices
55
+
56
+ ## Threat Model
57
+
58
+ ### MiniSearch's Security Boundaries
59
+
60
+ MiniSearch is designed as a privacy-focused search application with the following security assumptions:
61
+
62
+ **Trust Boundaries:**
63
+ - **Browser Environment**: The application runs entirely in the user's browser
64
+ - **Server Component**: Optional backend for search and AI processing
65
+ - **AI Models**: Local or remote AI processing with configurable endpoints
66
+
67
+ **Data Flow:**
68
+ - User queries are sent to SearXNG instances (configurable)
69
+ - AI processing can be local (Wllama) or remote (API endpoints)
70
+ - Search history is stored locally in the browser
71
+ - No tracking or analytics by default
72
+
73
+ **Security Controls:**
74
+ - Optional access key protection for deployment
75
+ - Configurable AI endpoints for privacy
76
+ - Local-first data storage
77
+ - No third-party tracking or analytics
78
+
79
+ **Potential Risks:**
80
+ - Malicious SearXNG instances could log queries
81
+ - Remote AI endpoints could access user queries
82
+ - Browser extensions could interfere with the application
83
+ - Man-in-the-middle attacks without HTTPS
84
+
85
+ ## Security Best Practices
86
+
87
+ ### For Users
88
+
89
+ - Always use HTTPS when accessing MiniSearch instances
90
+ - Configure trusted SearXNG instances
91
+ - Use local AI models for maximum privacy
92
+ - Set access keys for deployed instances
93
+ - Keep browsers updated
94
+
95
+ ### For Deployers
96
+
97
+ - Use the official Docker image
98
+ - Configure environment variables securely
99
+ - Set up proper access controls
100
+ - Use HTTPS in production
101
+ - Regularly update dependencies
102
+ - Monitor for security advisories
103
+
104
+ ## Security Features
105
+
106
+ - **Access Key Protection**: Optional password-based access control
107
+ - **Configurable Endpoints**: Users control search and AI providers
108
+ - **Local Processing**: AI models can run entirely in the browser
109
+ - **No Tracking**: Built without analytics or tracking
110
+ - **HTTPS Ready**: Designed for secure deployment
111
+
112
+ ## Security Updates
113
+
114
+ Security updates will be:
115
+ - Released as new versions
116
+ - Announced in release notes
117
+ - Coordinated with dependency updates when applicable
118
+
119
+ ## Security Team
120
+
121
+ The MiniSearch security team is currently the project maintainer:
122
+ - [@felladrin](https://github.com/felladrin) - Project Maintainer
123
+
124
+ ## Acknowledgments
125
+
126
+ We thank security researchers who help us keep MiniSearch secure. All valid security reports will be acknowledged in our release notes (with reporter permission).
127
+
128
+ ## Related Resources
129
+
130
+ - [GitHub Security Advisories](https://docs.github.com/en/code-security/security-advisories)
131
+ - [OWASP Web Security Testing Guide](https://owasp.org/www-project-web-security-testing-guide/)
132
+ - [Mozilla Security Guidelines](https://infosec.mozilla.org/guidelines)
.github/SUPPORT.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Support
2
+
3
+ ## Getting help
4
+
5
+ If you need help with MiniSearch, these are the best support channels.
6
+
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
14
+ - Discuss best practices
15
+
16
+ ### Bug reports
17
+
18
+ Report bugs through [GitHub Issues](https://github.com/felladrin/MiniSearch/issues/new?template=bug_report.yml). Before opening an issue, check whether the problem has already been reported and include enough detail to reproduce it.
19
+
20
+ ### Security vulnerabilities
21
+
22
+ Do not report security vulnerabilities in a public issue. Use [GitHub's Private Vulnerability Reporting](https://github.com/felladrin/MiniSearch/security/advisories/new) instead.
23
+
24
+ ### Live demo
25
+
26
+ Try MiniSearch in your browser at [felladrin-minisearch.hf.space](https://felladrin-minisearch.hf.space) before deploying your own instance.
27
+
28
+ ## Community
29
+
30
+ - Follow [@felladrin](https://github.com/felladrin) for project updates
31
+ - Star the repository to show your support
32
+ - Contribute by opening pull requests after reading the [contribution guidelines](CONTRIBUTING.md)
33
+
34
+ ## Response times
35
+
36
+ This is a community-maintained project. While the maintainer tries to respond to issues and discussions promptly, response times may vary.
.github/hf-space-config.yml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ title: MiniSearch
2
+ emoji: ๐Ÿ‘Œ๐Ÿ”
3
+ colorFrom: yellow
4
+ colorTo: yellow
5
+ sdk: docker
6
+ short_description: Minimalist web-searching app with browser-based AI assistant
7
+ pinned: true
8
+ custom_headers:
9
+ cross-origin-embedder-policy: require-corp
10
+ cross-origin-opener-policy: same-origin
11
+ cross-origin-resource-policy: cross-origin
.github/workflows/ci.yml ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master]
6
+ pull_request:
7
+ branches: [main, master]
8
+
9
+ jobs:
10
+ build-test:
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ contents: read
14
+
15
+ strategy:
16
+ matrix:
17
+ node-version: [lts/*, current]
18
+
19
+ steps:
20
+ - name: Checkout repository
21
+ uses: actions/checkout@v7
22
+
23
+ - name: Set up Node.js
24
+ uses: actions/setup-node@v7
25
+ with:
26
+ node-version: ${{ matrix.node-version }}
27
+ cache: npm
28
+
29
+ - name: Install dependencies
30
+ run: npm ci
31
+
32
+ - name: Run npm audit
33
+ run: npm audit --audit-level=high
34
+
35
+ - name: Run lint
36
+ run: npm run lint
37
+
38
+ - name: Lint Dockerfile
39
+ uses: hadolint/hadolint-action@v3.5.0
40
+ with:
41
+ dockerfile: Dockerfile
42
+
43
+ - name: Check formatting
44
+ run: npm run format:check
45
+
46
+ - name: Run tests
47
+ run: npm run test:coverage
.github/workflows/deploy-to-hugging-face.yml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to Hugging Face
2
+
3
+ on:
4
+ workflow_dispatch:
5
+
6
+ jobs:
7
+ sync-to-hf:
8
+ name: Sync to Hugging Face Spaces
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ steps:
13
+ - uses: actions/checkout@v7
14
+ # Pin to the tip of the action's `v1` branch, which is where its build
15
+ # output lives. Its release tags point at source commits, and `dist/` is
16
+ # gitignored there, so a tag digest resolves to a tree with no
17
+ # `dist/index.js` and the step fails before it runs.
18
+ - uses: JacobLinCool/huggingface-sync@48eb49940149dc1015d8294621cb203635fe9ff3 # v1 branch, 2026-05-12
19
+ with:
20
+ github: ${{ secrets.GITHUB_TOKEN }}
21
+ user: ${{ vars.HF_SPACE_OWNER }}
22
+ space: ${{ vars.HF_SPACE_NAME }}
23
+ token: ${{ secrets.HF_TOKEN }}
24
+ configuration: ".github/hf-space-config.yml"
.github/workflows/on-pull-request-to-main.yml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: On Pull Request To Main
2
+ on:
3
+ pull_request:
4
+ types: [opened, synchronize, reopened]
5
+ branches: ["main"]
6
+ jobs:
7
+ check-docker:
8
+ if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-check-docker') }}
9
+ permissions:
10
+ contents: read
11
+ uses: ./.github/workflows/reusable-check-docker.yml
.github/workflows/on-push-to-main.yml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ name: On Push To Main
2
+ on:
3
+ push:
4
+ branches: ["main"]
5
+ jobs:
6
+ check-docker:
7
+ permissions:
8
+ contents: read
9
+ uses: ./.github/workflows/reusable-check-docker.yml
.github/workflows/publish-docker-image.yml ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish Docker Image
2
+
3
+ on:
4
+ workflow_dispatch:
5
+
6
+ jobs:
7
+ build-and-push-image:
8
+ name: Publish Docker Image to GitHub Packages
9
+ runs-on: ubuntu-latest
10
+ env:
11
+ REGISTRY: ghcr.io
12
+ IMAGE_NAME: ${{ github.repository }}
13
+ permissions:
14
+ contents: read
15
+ packages: write
16
+ steps:
17
+ - name: Checkout repository
18
+ uses: actions/checkout@v7
19
+ - name: Log in to the Container registry
20
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
21
+ with:
22
+ registry: ${{ env.REGISTRY }}
23
+ username: ${{ github.actor }}
24
+ password: ${{ secrets.GITHUB_TOKEN }}
25
+ - name: Extract metadata (tags, labels) for Docker
26
+ id: meta
27
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
28
+ with:
29
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
30
+ # `latest` replaces the `main` tag this action emits by default. With
31
+ # releases cut on demand, the image can sit several commits behind the
32
+ # branch, so naming it after the branch misleads. The
33
+ # `is_default_branch` guard stops a dispatch from a side branch from
34
+ # moving the tag for everyone.
35
+ tags: |
36
+ type=raw,value=latest,enable={{is_default_branch}}
37
+ - name: Set up Docker Buildx
38
+ uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
39
+ - name: Build and push Docker Image
40
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
41
+ with:
42
+ context: .
43
+ push: true
44
+ tags: ${{ steps.meta.outputs.tags }}
45
+ labels: ${{ steps.meta.outputs.labels }}
46
+ platforms: linux/amd64,linux/arm64
47
+ # BuildKit pushes these attestations with the registry credentials
48
+ # already in use, so no extra job permissions are needed.
49
+ provenance: mode=max
50
+ sbom: true
.github/workflows/reusable-check-docker.yml ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ on:
2
+ workflow_call:
3
+ inputs:
4
+ port:
5
+ type: string
6
+ default: "7860"
7
+ jobs:
8
+ check-docker-container:
9
+ name: Check Docker Container
10
+ runs-on: ubuntu-latest
11
+ env:
12
+ PORT: ${{ inputs.port }}
13
+ permissions:
14
+ contents: read
15
+ steps:
16
+ - uses: actions/checkout@v7
17
+ - run: docker compose -f docker-compose.production.yml up -d
18
+ - name: Check if main page is available
19
+ run: until curl -s -o /dev/null -w "%{http_code}" localhost:"$PORT" | grep 200; do sleep 1; done
20
+ timeout-minutes: 1
21
+
22
+ - uses: actions/setup-node@v7
23
+ with:
24
+ node-version-file: .nvmrc
25
+ cache: npm
26
+
27
+ - run: npm ci
28
+
29
+ - name: Install Playwright Chromium
30
+ run: npx playwright install --with-deps chromium
31
+
32
+ - name: Playwright e2e smoke
33
+ run: npm run test:e2e
34
+ env:
35
+ PLAYWRIGHT_BASE_URL: http://127.0.0.1:${{ inputs.port }}
36
+
37
+ - name: Upload Playwright report
38
+ if: ${{ failure() }}
39
+ uses: actions/upload-artifact@v7
40
+ with:
41
+ name: playwright-report
42
+ path: |
43
+ playwright-report/
44
+ test-results/
45
+ retention-days: 7
46
+
47
+ - if: ${{ always() }}
48
+ run: docker compose -f docker-compose.production.yml down
.github/workflows/scan-docker-image.yml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Scan Docker Image
2
+
3
+ # Deliberately not tied to pull requests. The image inherits its base OS
4
+ # packages from `node:lts-slim`, so findings arrive when upstream publishes an
5
+ # advisory rather than when someone changes the app, and gating pull requests on
6
+ # them would only produce a red check nobody can act on.
7
+ on:
8
+ schedule:
9
+ - cron: "0 6 * * 1"
10
+ workflow_dispatch:
11
+
12
+ jobs:
13
+ scan-published-image:
14
+ name: Scan Published Image for Vulnerabilities
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ contents: read
18
+ security-events: write
19
+ steps:
20
+ - name: Run Trivy on the published image
21
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
22
+ with:
23
+ image-ref: ghcr.io/${{ github.repository }}:latest
24
+ format: sarif
25
+ output: trivy-results.sarif
26
+ severity: HIGH,CRITICAL
27
+ # Without this, the report fills with advisories that have no patched
28
+ # version, drowning out the findings that can actually be fixed.
29
+ ignore-unfixed: true
30
+ - name: Upload results to code scanning
31
+ uses: github/codeql-action/upload-sarif@v4
32
+ with:
33
+ sarif_file: trivy-results.sarif
.github/workflows/security.yml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Security Scanning
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master]
6
+ pull_request:
7
+ branches: [main, master]
8
+
9
+ jobs:
10
+ secret-scanning:
11
+ name: Secret Scanning (Gitleaks)
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ contents: read
15
+ steps:
16
+ - name: Checkout repository (full history)
17
+ uses: actions/checkout@v7
18
+ with:
19
+ fetch-depth: 0
20
+
21
+ - name: Run Gitleaks
22
+ uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
23
+ env:
24
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
.github/workflows/stale.yml ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Stale
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "30 1 * * *"
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ stale:
10
+ name: Mark and Close Stale Items
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ issues: write
14
+ pull-requests: write
15
+ steps:
16
+ - name: Mark and close stale issues and pull requests
17
+ uses: actions/stale@v11
18
+ with:
19
+ days-before-stale: 60
20
+ # Counted from the moment an item is marked stale, so an issue is
21
+ # closed once it reaches 90 days of inactivity.
22
+ days-before-close: 30
23
+ # Pull requests are labelled and commented on, but never closed
24
+ # automatically: a stale branch is still worth rebasing.
25
+ days-before-pr-close: -1
26
+ stale-issue-label: stale
27
+ stale-pr-label: stale
28
+ stale-issue-message: >
29
+ This issue has been inactive for 60 days. It will be closed in 30 days if there is no
30
+ further activity. Comment to keep it open.
31
+ stale-pr-message: >
32
+ This pull request has been inactive for 60 days. It will not be closed automatically,
33
+ but please rebase it on `main` and comment if it is still relevant.
34
+ close-issue-message: >
35
+ This issue was closed due to inactivity. Comment if it is still relevant and it will be
36
+ reopened.
37
+ exempt-issue-labels: good first issue,security,pinned
38
+ exempt-pr-labels: good first issue,security,pinned
.gitignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .DS_Store
3
+ /client/dist
4
+ /server/models
5
+ .vscode
6
+ /vite-build-stats.html
7
+ .env
8
+ /coverage
9
+ .playwright-cli
10
+ *.tsbuildinfo
11
+
12
+ # Playwright
13
+ test-results/
14
+ playwright-report/
15
+ blob-report/
16
+ playwright/.cache/
.hadolint.yaml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hadolint configuration for MiniSearch
2
+ # Suppress rules that are not applicable to our Dockerfile
3
+
4
+ ignored:
5
+ - DL3064
6
+ - DL3008
7
+ - SC2046
8
+ - DL3025
9
+ # We run as the named `node` user that the base image already provides, by design.
10
+ - DL3066
.husky/pre-commit ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env sh
2
+
3
+ # Secret scanning - runs on every commit to catch credentials before they land in git
4
+ # Requires gitleaks to be installed globally (https://github.com/gitleaks/gitleaks)
5
+ # Skips silently if not installed; CI enforces the check regardless.
6
+ if command -v gitleaks >/dev/null 2>&1; then
7
+ gitleaks detect --source . --redact --exit-code 1 || exit 1
8
+ else
9
+ echo "WARNING: gitleaks not installed. Skipping pre-commit secret scanning." >&2
10
+ fi
11
+
12
+ # Lint and format staged JS/TS files
13
+ if git diff --cached --name-only | grep -qE '\.(ts|tsx|js|jsx|mjs|cjs)$'; then
14
+ biome="$(git rev-parse --show-toplevel)/node_modules/.bin/biome"
15
+ if [ ! -x "$biome" ]; then
16
+ echo "pre-commit: @biomejs/biome not found in node_modules. Run 'npm ci' and try again." >&2
17
+ exit 1
18
+ fi
19
+ "$biome" check --write --staged --no-errors-on-unmatched
20
+ fi
21
+ git diff --cached --name-only --diff-filter=d | while read -r file; do [ -f "$file" ] && git add "$file"; done
.npmrc ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ legacy-peer-deps = true
2
+ min-release-age = 3
3
+
4
+ # onnxruntime-node's postinstall downloads a CUDA 12 nupkg on linux/x64 and
5
+ # unpacks it with adm-zip, which follows symlinks at the extraction destination
6
+ # (GHSA-vwc7-r8mq-g2x9, unfixed upstream). Both inference services pin
7
+ # `executionProviders: ["cpu"]`, so those binaries are never loaded.
8
+ onnxruntime-node-install = skip
.nvmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ lts/*
Dockerfile ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:lts-slim
2
+
3
+ ARG SEARXNG_COMMIT_SHA="6da6eee265daeb4a62ab638d6921522bf405de69"
4
+
5
+ ENV PORT=7860
6
+ EXPOSE $PORT
7
+
8
+ ARG USERNAME=node
9
+ ARG HOME_DIR=/home/${USERNAME}
10
+ ARG APP_DIR=${HOME_DIR}/app
11
+
12
+ # The slim base omits tools the full `node` image ships implicitly: `git` for
13
+ # the SearXNG checkout and the build's commit hash, `curl` for the HEALTHCHECK
14
+ # below, `openssl` for the SearXNG secret key, and `ca-certificates` for both
15
+ # the clone and pip.
16
+ RUN apt-get update && \
17
+ apt-get install -y --no-install-recommends \
18
+ ca-certificates \
19
+ curl \
20
+ git \
21
+ openssl \
22
+ python3 \
23
+ python3-venv && \
24
+ apt-get clean && \
25
+ rm -rf /var/lib/apt/lists/*
26
+
27
+ RUN mkdir -p /usr/local/searxng /etc/searxng && \
28
+ chown -R ${USERNAME}:${USERNAME} /usr/local/searxng /etc/searxng && \
29
+ chmod 755 /etc/searxng
30
+
31
+ WORKDIR /usr/local/searxng
32
+ RUN python3 -m venv searxng-venv && \
33
+ chown -R ${USERNAME}:${USERNAME} /usr/local/searxng/searxng-venv && \
34
+ /usr/local/searxng/searxng-venv/bin/pip install --upgrade pip && \
35
+ /usr/local/searxng/searxng-venv/bin/pip install wheel setuptools pyyaml lxml
36
+
37
+ RUN git clone https://github.com/searxng/searxng.git /usr/local/searxng/searxng-src && \
38
+ git -C /usr/local/searxng/searxng-src checkout $SEARXNG_COMMIT_SHA && \
39
+ chown -R ${USERNAME}:${USERNAME} /usr/local/searxng/searxng-src
40
+
41
+ ARG SEARXNG_SETTINGS_PATH="/etc/searxng/settings.yml"
42
+
43
+ COPY --chown=${USERNAME}:${USERNAME} searxng-settings.yml $SEARXNG_SETTINGS_PATH
44
+
45
+ WORKDIR /usr/local/searxng/searxng-src
46
+ RUN chmod 644 $SEARXNG_SETTINGS_PATH && \
47
+ sed -i 's/ultrasecretkey/'$(openssl rand -hex 32)'/g' $SEARXNG_SETTINGS_PATH && \
48
+ /usr/local/searxng/searxng-venv/bin/pip install -r requirements.txt && \
49
+ /usr/local/searxng/searxng-venv/bin/pip install --no-build-isolation -e .
50
+
51
+ USER ${USERNAME}
52
+
53
+ WORKDIR ${APP_DIR}
54
+
55
+ COPY --chown=${USERNAME}:${USERNAME} ./package.json ./package-lock.json ./.npmrc ./
56
+
57
+ RUN npm ci
58
+
59
+ COPY --chown=${USERNAME}:${USERNAME} . .
60
+
61
+ # The commit hash is optional build metadata, so a build context without a
62
+ # usable repository must not fail the build. This happens when building from a
63
+ # git worktree, where `.git` is a file pointing at a gitdir outside the context;
64
+ # git then treats every command as fatal, including `config --global`.
65
+ RUN git config --global --add safe.directory ${APP_DIR} 2>/dev/null || true; \
66
+ git rev-parse --short HEAD >/dev/null 2>&1 || \
67
+ echo "WARNING: no usable git repository in the build context, so the app will report an empty commit hash."; \
68
+ npm run build
69
+
70
+ HEALTHCHECK --start-period=60s --interval=30s --timeout=10s --retries=3 CMD curl -fsS http://localhost:${PORT}/status || exit 1
71
+
72
+ ENTRYPOINT [ "/bin/sh", "-c" ]
73
+
74
+ CMD ["(cd /usr/local/searxng/searxng-src && /usr/local/searxng/searxng-venv/bin/python -m searx.webapp > /dev/null 2>&1) & npm start -- --host"]
README.md ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: MiniSearch
3
+ emoji: ๐Ÿ‘Œ๐Ÿ”
4
+ colorFrom: yellow
5
+ colorTo: yellow
6
+ sdk: docker
7
+ short_description: Minimalist web-searching app with browser-based AI assistant
8
+ pinned: true
9
+ custom_headers:
10
+ cross-origin-embedder-policy: require-corp
11
+ cross-origin-opener-policy: same-origin
12
+ cross-origin-resource-policy: cross-origin
13
+ ---
14
+
15
+ <div align="center">
16
+ <a href="https://felladrin-minisearch.hf.space">
17
+ <img src="client/public/favicon.png" alt="MiniSearch logo" width="76" height="76" />
18
+ </a>
19
+ <h1>MiniSearch</h1>
20
+ <p><strong>Private AI search that runs in your browser.</strong></p>
21
+ <p>
22
+ <a href="https://felladrin-minisearch.hf.space"><img alt="Live Demo" src="https://img.shields.io/badge/Live_Demo-Hugging_Face-FFD21E?logo=huggingface" /></a>
23
+ <a href="https://github.com/felladrin/MiniSearch/actions/workflows/ci.yml"><img alt="CI status" src="https://github.com/felladrin/MiniSearch/actions/workflows/ci.yml/badge.svg" /></a>
24
+ <a href="https://github.com/felladrin/MiniSearch/pkgs/container/minisearch"><img alt="Docker image" src="https://img.shields.io/badge/ghcr.io-felladrin%2Fminisearch-2496ED?logo=docker&logoColor=white" /></a>
25
+ <a href="license.txt"><img alt="License" src="https://img.shields.io/github/license/felladrin/MiniSearch" /></a>
26
+ <a href="https://github.com/felladrin/MiniSearch/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/felladrin/MiniSearch?style=flat&logo=github" /></a>
27
+ </p>
28
+ <img src="https://github.com/user-attachments/assets/f8d72a8e-a725-42e9-9358-e6ebade2acb2" alt="MiniSearch answering a query: an AI response with citations above the search results" width="720" />
29
+ </div>
30
+
31
+ ## About
32
+
33
+ MiniSearch is a self-hosted search engine with an AI assistant.
34
+
35
+ The AI can run entirely inside your browser tab, on GPU or CPU, so a working setup needs no API key, no separate inference server, and no third party seeing your queries. Web results come from a bundled [SearXNG](https://github.com/searxng/searxng) metasearch instance, are reranked locally, and the whole thing ships as a single Docker container.
36
+
37
+ ## Features
38
+
39
+ - **Private by design.** No tracking, no telemetry, no accounts. Search history, cached results, and chats are stored in your browser and never leave your machine.
40
+ - **AI in your browser.** Pick from several curated models (135M to 4B parameters) that run on WebGPU where available and on CPU elsewhere. Models are downloaded once and cached by the browser.
41
+ - **Any backend you like.** Connect an OpenAI-compatible API (Ollama, LM Studio, vLLM, llama.cpp server, or a hosted provider), use the crowdsourced AI Horde, or let the server proxy your own API without exposing its key.
42
+ - **A real search pipeline.** Text and image results aggregated by SearXNG, reranked locally by a cross-encoder model, cached, and rate-limited; all inside the container.
43
+ - **Answers you can verify.** Responses cite the sources they draw from, support follow-up questions with conversation memory, reveal the model's reasoning on demand, and can be read aloud.
44
+ - **Local history and analytics.** Fuzzy-searchable history with pinning and full-session restore, plus usage statistics and an activity heatmap. Retention is configurable, and storage stays in the browser.
45
+ - **Fits your workflow.** Set it as your browser's default search engine, trigger it from Raycast, embed it in your own pages, and optionally protect your instance with access keys.
46
+
47
+ ## Quick start
48
+
49
+ Run the published image:
50
+
51
+ ```bash
52
+ docker run -p 7860:7860 ghcr.io/felladrin/minisearch
53
+ ```
54
+
55
+ Then open <http://localhost:7860> and start searching.
56
+
57
+ <details>
58
+ <summary>Pin to a specific build</summary>
59
+
60
+ `latest` moves on every release, so it changes under you. To stay on a known-good build, pin the digest, which always identifies the same image:
61
+
62
+ ```bash
63
+ docker inspect --format '{{index .RepoDigests 0}}' ghcr.io/felladrin/minisearch:latest
64
+ docker run -p 7860:7860 ghcr.io/felladrin/minisearch@sha256:1a2b3c...
65
+ ```
66
+
67
+ The version the running instance reports (in the menu, and under `build` on `/status`) tells you which commit it was built from.
68
+
69
+ Images carry provenance and SBOM attestations, which you can inspect with:
70
+
71
+ ```bash
72
+ docker buildx imagetools inspect ghcr.io/felladrin/minisearch:latest
73
+ ```
74
+
75
+ </details>
76
+
77
+ <details>
78
+ <summary>Use Docker Compose</summary>
79
+
80
+ Add the service to your `docker-compose.yml`:
81
+
82
+ ```yaml
83
+ services:
84
+ minisearch:
85
+ image: ghcr.io/felladrin/minisearch:latest
86
+ ports:
87
+ - "7860:7860"
88
+ ```
89
+
90
+ </details>
91
+
92
+ <details>
93
+ <summary>Build from source</summary>
94
+
95
+ ```bash
96
+ git clone https://github.com/felladrin/MiniSearch.git
97
+ cd MiniSearch
98
+ docker compose -f docker-compose.production.yml up --build
99
+ ```
100
+
101
+ </details>
102
+
103
+ <details>
104
+ <summary>Host it on Hugging Face</summary>
105
+
106
+ [Duplicate the Space](https://huggingface.co/spaces/Felladrin/MiniSearch?duplicate=true) to get your own hosted instance, no server required. Environment variables can be set in the Space settings.
107
+
108
+ </details>
109
+
110
+ ## How it works
111
+
112
+ ```mermaid
113
+ flowchart LR
114
+ subgraph browser [Your browser]
115
+ UI[Search UI]
116
+ LocalAI[In-browser model<br/>WebGPU or CPU]
117
+ Storage[(IndexedDB<br/>history and cache)]
118
+ end
119
+ subgraph container [Docker container]
120
+ Server[App server]
121
+ SearXNG[SearXNG<br/>metasearch]
122
+ Reranker[Reranker<br/>ONNX Runtime]
123
+ end
124
+ UI <--> Storage
125
+ UI -->|search| Server
126
+ Server --> SearXNG
127
+ SearXNG --> Web((Web))
128
+ Server --> Reranker
129
+ UI -.->|in-browser inference| LocalAI
130
+ UI -.->|remote inference, optional| RemoteAPI[OpenAI-compatible API<br/>or AI Horde]
131
+ ```
132
+
133
+ Your query goes to the app server, which asks the bundled SearXNG instance to aggregate results from multiple search engines. The server reranks them with a small cross-encoder model before returning them, and the browser caches them locally. If the AI response is enabled, the assistant reads the top results and writes a cited answer, either with a model running in your browser or through the backend you configured. The full picture is in [docs/overview.md](docs/overview.md).
134
+
135
+ ## Configuration
136
+
137
+ MiniSearch works out-of-the-box, but you can customize it if you want. More details at [docs/configuration.md](docs/configuration.md).
138
+
139
+ ## FAQ
140
+
141
+ <details>
142
+ <summary>How do I make it my browser's default search engine?</summary>
143
+
144
+ Add a custom search engine in your browser settings using the pattern `http://localhost:7860/?q=%s`, replacing the host with your instance's address. Your search term replaces `%s`.
145
+
146
+ </details>
147
+
148
+ <details>
149
+ <summary>How do I search from Raycast?</summary>
150
+
151
+ Add [this Quicklink](https://ray.so/quicklinks/shared?quicklinks=%7B%22link%22:%22https:%5C/%5C/felladrin-minisearch.hf.space%5C/?q%3D%7BQuery%7D%22,%22name%22:%22MiniSearch%22%7D) to Raycast, and edit it to point to your own instance if you have one.
152
+
153
+ </details>
154
+
155
+ <details>
156
+ <summary>Can I use my own models through an OpenAI-compatible API?</summary>
157
+
158
+ Yes. Open the menu, set "AI Processing Location" to `Remote server (OpenAI-compatible API)`, then fill in the base URL, and optionally an API key and a model name. If the model is left blank, it is picked from the ones the API lists.
159
+
160
+ </details>
161
+
162
+ <details>
163
+ <summary>Can others use my instance with my API key without seeing it?</summary>
164
+
165
+ Yes. Configure the `INTERNAL_OPENAI_COMPATIBLE_API_*` variables from the [Configuration](#configuration) table and restart the container. A new option with the name you chose appears in the "AI Processing Location" menu, and the key stays on the server.
166
+
167
+ </details>
168
+
169
+ ## Contributing
170
+
171
+ See the [Contributing Guidelines](.github/CONTRIBUTING.md), [Code of Conduct](.github/CODE_OF_CONDUCT.md), and [Security Policy](.github/SECURITY.md).
172
+
173
+ ## Releases
174
+
175
+ Notable changes are documented in the [Changelog](changelog.md). Docker images are published to [ghcr.io/felladrin/minisearch](https://github.com/felladrin/MiniSearch/pkgs/container/minisearch) as `latest` and `YYYY.M.D-<short-sha>`; pin to a dated tag if you want a stable reference.
176
+
177
+ ## License
178
+
179
+ [Apache License 2.0](license.txt)
agents.md ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MiniSearch Agent Guidelines
2
+
3
+ This is your navigation hub. Start here, follow the links, and return when you need orientation.
4
+
5
+ ## Before You Start
6
+
7
+ **New to this codebase?** Read in this order:
8
+ 1. `docs/quick-start.md` - Get it running
9
+ 2. `docs/overview.md` - Understand the system
10
+ 3. `docs/project-structure.md` - Navigate the code
11
+
12
+ **Making changes?** Check:
13
+ - `docs/coding-conventions.md` - Code style
14
+ - `docs/development-commands.md` - Available commands
15
+ - `docs/pull-requests.md` - How to submit
16
+
17
+ ## Repository Map
18
+
19
+ ### Getting Started
20
+ - **`docs/quick-start.md`** - Installation, first run, verification
21
+ - **`docs/overview.md`** - System architecture and data flow
22
+ - **`docs/project-structure.md`** - Directory layout and component organization
23
+
24
+ ### Configuration & Setup
25
+ - **`docs/configuration.md`** - Environment variables and settings reference
26
+ - **`docs/security.md`** - Access control, privacy, and security model
27
+
28
+ ### Core Functionality
29
+ - **`docs/api.md`** - HTTP endpoint reference: parameters, responses, and error codes
30
+ - **`docs/ai-integration.md`** - AI inference types (Wllama, OpenAI, AI Horde, Internal)
31
+ - **`docs/ui-components.md`** - Component architecture and PubSub patterns
32
+ - **`docs/search-history.md`** - History database schema and management
33
+ - **`docs/conversation-memory.md`** - Token budgeting and rolling summaries
34
+ - **`docs/reranking.md`** - Reranker subsystem and model lifecycle
35
+ - **`docs/page-content.md`** - Reading result pages to ground AI answers
36
+ - **`docs/glossary.md`** - Codebase-specific terms and domain concepts
37
+
38
+ ### Development
39
+ - **`docs/development-commands.md`** - Docker, npm, and testing commands
40
+ - **`docs/failure-injection.md`** - Graceful-degradation test matrix
41
+ - **`docs/coding-conventions.md`** - Style guide and patterns
42
+ - **`docs/pull-requests.md`** - PR process and merge philosophy
43
+ - **`docs/core-technologies.md`** - Technology stack and dependencies
44
+ - **`docs/design.md`** - UI/UX design principles
45
+
46
+ ## Agent Decision Tree
47
+
48
+ ```
49
+ Need to:
50
+ โ”œโ”€โ”€ Add a feature?
51
+ โ”‚ โ”œโ”€โ”€ UI component โ†’ docs/ui-components.md
52
+ โ”‚ โ”œโ”€โ”€ AI integration โ†’ docs/ai-integration.md
53
+ โ”‚ โ”œโ”€โ”€ Search functionality โ†’ client/modules/search.ts
54
+ โ”‚ โ””โ”€โ”€ Settings option โ†’ docs/configuration.md
55
+ โ”œโ”€โ”€ Fix a bug?
56
+ โ”‚ โ”œโ”€โ”€ UI issue โ†’ Check component + PubSub channels
57
+ โ”‚ โ”œโ”€โ”€ AI not working โ†’ docs/ai-integration.md + browser console
58
+ โ”‚ โ”œโ”€โ”€ Search failing โ†’ Check SearXNG + server hooks
59
+ โ”‚ โ””โ”€โ”€ Build error โ†’ docs/development-commands.md
60
+ โ”œโ”€โ”€ Configure deployment?
61
+ โ”‚ โ”œโ”€โ”€ Environment variables โ†’ docs/configuration.md
62
+ โ”‚ โ”œโ”€โ”€ Access control โ†’ docs/security.md
63
+ โ”‚ โ””โ”€โ”€ Docker setup โ†’ docs/overview.md
64
+ โ””โ”€โ”€ Understand data flow?
65
+ โ”œโ”€โ”€ Search flow โ†’ client/modules/search.ts
66
+ โ”œโ”€โ”€ AI generation โ†’ client/modules/textGeneration.ts
67
+ โ”œโ”€โ”€ State management โ†’ docs/ui-components.md
68
+ โ””โ”€โ”€ History/Chat โ†’ docs/search-history.md + docs/conversation-memory.md
69
+ ```
70
+
71
+ ## Key Files Reference
72
+
73
+ ### Entry Points
74
+ - `client/index.tsx` - React app initialization
75
+ - `vite.config.ts` - Vite dev server with hooks
76
+ - `Dockerfile` - Multi-stage container build
77
+
78
+ ### Business Logic Modules
79
+ - `client/modules/search.ts` - Search orchestration and caching
80
+ - `client/modules/textGeneration.ts` - AI response flow
81
+ - `client/modules/pubSub.ts` - All PubSub channels
82
+ - `client/modules/settings.ts` - Settings management
83
+ - `client/modules/history.ts` - Search history database
84
+
85
+ ### Server-Side Modules
86
+ - `server/searchEndpointServerHook.ts` - `/search` endpoints
87
+ - `server/pageContentEndpointServerHook.ts` - `/page-content` endpoint
88
+ - `server/internalApiEndpointServerHook.ts` - `/inference` proxy
89
+ - `server/webSearchService.ts` - SearXNG integration
90
+ - `server/rerankerService.ts` - Local result reranking
91
+
92
+ ### UI Components
93
+ - `client/components/App/` - Application shell with error boundaries
94
+ - `client/components/Search/Form/` - Search input and form
95
+ - `client/components/Search/Results/` - Textual and graphical results display
96
+ - `client/components/Search/History/` - History drawer and button
97
+ - `client/components/AiResponse/` - AI response display and chat interface
98
+ - `client/components/Pages/Main/` - Main page layout
99
+ - `client/components/Pages/Main/Menu/` - Settings drawers (AI, Search, Interface, History, Voice, Actions)
100
+ - `client/components/Pages/AccessPage.tsx` - Access key validation page
101
+ - `client/components/Analytics/SearchStats.tsx` - Search analytics cards
102
+ - `client/components/Logs/` - Application logging modal
103
+ - `client/components/Settings/HistorySettings.tsx` - History configuration UI
104
+
105
+ ### Client Modules
106
+ - `client/modules/pubSub.ts` - All PubSub channels for state management
107
+ - `client/modules/search.ts` - Search orchestration and IndexedDB caching
108
+ - `client/modules/textGeneration.ts` - AI response generation and chat handling
109
+ - `client/modules/textGenerationWithWllama.ts` - Browser-based inference
110
+ - `client/modules/textGenerationWithOpenAi.ts` - OpenAI-compatible API inference
111
+ - `client/modules/textGenerationWithHorde.ts` - AI Horde distributed inference
112
+ - `client/modules/textGenerationWithInternalApi.ts` - Internal API proxy inference
113
+ - `client/modules/settings.ts` - Default settings and inference type definitions
114
+ - `client/modules/history.ts` - IndexedDB persistence for searches and chat
115
+ - `client/modules/wllama.ts` - Wllama model configuration and management
116
+ - `client/modules/webGpu.ts` - WebGPU availability detection (`"gpu" in navigator`)
117
+ - `client/modules/querySuggestions.ts` - Search suggestion UI, stored in IndexedDB
118
+ - `client/modules/relatedSearchQuery.ts` - Generates related search queries
119
+ - `client/modules/followUpQuestions.ts` - Generates follow-up questions via `followUpQuestionPubSub`
120
+ - `client/modules/accessKey.ts` - Validates and stores access keys using argon2id hashing and localStorage
121
+ - `client/modules/parentWindow.ts` - PostMessage API for embedding in parent windows
122
+ - `client/modules/searchTokenHash.ts` - CSRF protection token generation
123
+ - `client/modules/pageContent.ts` - Requests extracted page text from `/page-content`
124
+ - `client/modules/systemPrompt.ts` - System prompt templates
125
+ - `client/modules/logEntries.ts` - Application logging with unique IDs
126
+ - `client/modules/appInfo.ts` - Application metadata and version info
127
+ - `client/modules/textToSpeech.ts` - Reads answers aloud: engine selection, fallback, and the playback queue
128
+ - `client/modules/textToSpeechWorker.ts` - Synthesizes sentences off the main thread
129
+ - `client/modules/textToSpeechWorkerProtocol.ts` - Message types shared by both sides of the worker
130
+ - `client/modules/piper.ts` - The only module that imports `@diffusionstudio/vits-web`
131
+ - `client/modules/speechToText.ts` - Dictation: microphone capture, the worker's lifecycle, and the `SpeechRecognition` fallback
132
+ - `client/modules/speechToTextWorker.ts` - Runs the Moonshine transcriber off the main thread
133
+ - `client/modules/speechToTextWorkerProtocol.ts` - Message types shared by both sides of the dictation worker
134
+ - `client/modules/moonshine.ts` - The only module that imports `@moonshine-ai/moonshine-wasm`
135
+ - `client/modules/keyboard.ts` - Keyboard shortcut handling
136
+ - `client/modules/stringFormatters.ts` - Text formatting utilities
137
+ - `client/modules/types.ts` - Shared TypeScript type definitions
138
+
139
+ ### Server Modules
140
+ - `server/searchEndpointServerHook.ts` - `/search/text` and `/search/images` endpoints
141
+ - `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
142
+ - `server/internalApiEndpointServerHook.ts` - `/inference` proxy to self-hosted API
143
+ - `server/validateAccessKeyServerHook.ts` - Access key validation endpoint
144
+ - `server/statusEndpointServerHook.ts` - `/status` health check endpoint
145
+ - `server/configEndpointServerHook.ts` - `/api/config` runtime client configuration
146
+ - `server/rerankerServiceHook.ts` - Reranker model lifecycle management
147
+ - `server/compressionServerHook.ts` - gzip/brotli compression for responses
148
+ - `server/crossOriginServerHook.ts` - COOP/COEP headers for SharedArrayBuffer
149
+ - `server/dictationModelServerHook.ts` - `/dictation-models/` endpoint: serves the pinned speech-to-text model files from the instance
150
+ - `server/cacheServerHook.ts` - Cache-Control headers (preview server only)
151
+ - `server/webSearchService.ts` - SearXNG integration with circuit breaker and retry logic
152
+ - `server/pageContentService.ts` - Reads result pages and extracts query-relevant passages
153
+ - `server/utils/publicUrl.ts` - Blocks private and reserved addresses before a server-side fetch
154
+ - `server/rerankerService.ts` - Reranker service (ONNX Runtime inference)
155
+ - `server/rankSearchResults.ts` - Score-based filtering and result reordering
156
+ - `server/searchToken.ts` - CSRF token generation and storage
157
+ - `server/verifiedTokens.ts` - In-memory `Set<string>` of verified session tokens
158
+ - `server/verifyTokenAndRateLimit.ts` - Token verification and rate limiting
159
+ - `server/handleTokenVerification.ts` - Search token validation logic
160
+ - `server/searchesSinceLastRestart.ts` - In-memory search counters for analytics
161
+ - `server/downloadFileFromHuggingFaceRepository.ts` - Downloads model files from HuggingFace
162
+
163
+ ### Hooks
164
+ - `client/hooks/useSearchHistory.ts` - Search history management from IndexedDB
165
+ - `client/hooks/useHistoryRestore.ts` - Restores full search state from history
166
+ - `client/hooks/useDrawerState.ts` - Drawer open/close state with logging
167
+ - `client/hooks/useScreenWakeLock.ts` - Keeps the screen awake while a response is generating
168
+
169
+ ## Common Tasks Quick Reference
170
+
171
+ ### Add a new AI model
172
+ 1. Add to `client/modules/wllama.ts`
173
+ 2. Update `docs/ai-integration.md`
174
+ 3. Update `docs/configuration.md` defaults
175
+
176
+ ### Add a new setting
177
+ 1. Add to `client/modules/settings.ts` default object
178
+ 2. Add UI in `client/components/Pages/Main/Menu/`
179
+ 3. Update `docs/configuration.md` settings table
180
+
181
+ ### Modify search behavior
182
+ 1. Edit `client/modules/search.ts`
183
+ 2. Update `server/webSearchService.ts` if server-side changes needed
184
+ 3. Check `server/rerankerService.ts` if reranking affected
185
+
186
+ ### Fix UI state issues
187
+ 1. Check PubSub channels in `client/modules/pubSub.ts`
188
+ 2. Verify component subscriptions in `docs/ui-components.md`
189
+ 3. Ensure proper state updates in business logic modules
190
+
191
+ ### Analyze test coverage
192
+ 1. Run `npm run test:coverage` to generate reports
193
+ 2. Check `coverage/coverage-summary.json` for quick metrics
194
+ 3. See `docs/development-commands.md` for full coverage analysis guide
195
+
196
+ ## Quality Gates
197
+
198
+ Before any change:
199
+ ```bash
200
+ npm run lint
201
+ ```
202
+
203
+ Inside the dev container, the same command is `docker compose exec development-server npm run lint`.
204
+
205
+ This runs:
206
+ - Biome (formatting/linting)
207
+ - TypeScript (type checking)
208
+ - knip (dead code detection)
209
+ - jscpd (copy-paste detection)
210
+ - Custom architectural linter
211
+ - Doc gardening and documentation validator scripts
212
+
213
+ ### A bare `tsc` is not a full typecheck
214
+
215
+ The project is split across three TypeScript configs, and the default one covers less than its name suggests:
216
+
217
+ | Config | Covers |
218
+ | --- | --- |
219
+ | `tsconfig.json` | `client`, `shared`, `test` |
220
+ | `tsconfig.node.json` | `vite.config.ts`, `server`, `shared` |
221
+ | `tsconfig.eval.json` | `eval`, `shared` |
222
+
223
+ 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.
224
+
225
+ ## Agent-First Principles
226
+
227
+ **Repository as System of Record:**
228
+ - All knowledge lives in versioned docs/ structure
229
+ - This file is your entry point - start here
230
+ - Follow links, don't assume - verify in code
231
+
232
+ **Context Efficiency:**
233
+ - Use this map to navigate quickly
234
+ - Return to this file when context drifts
235
+ - Follow the decision tree for common tasks
236
+
237
+ **Architecture & Boundaries:**
238
+ - Respect PubSub boundaries - don't cross concerns
239
+ - Client vs server - keep them separate
240
+ - Feature-based organization - one folder per feature
241
+
242
+ **Documentation Maintenance:**
243
+ - Update these docs when you learn something new
244
+ - Add cross-references when linking concepts
245
+ - Keep examples current with actual code
246
+
247
+ ## Technology Stack
248
+
249
+ React + TypeScript + Mantine UI v9, with privacy-first architecture.
250
+ See `docs/core-technologies.md` for complete dependency list and selection criteria.
251
+
252
+ ## Need Help?
253
+
254
+ 1. Check relevant doc in `docs/`
255
+ 2. Read the module code in `client/modules/` or `server/`
256
+ 3. Look at similar existing implementations
257
+ 4. Run `npm run lint` to validate changes
biome.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "ignoreUnknown": false
10
+ },
11
+ "formatter": {
12
+ "enabled": true,
13
+ "indentStyle": "space"
14
+ },
15
+ "linter": {
16
+ "enabled": true,
17
+ "rules": {
18
+ "preset": "recommended"
19
+ }
20
+ },
21
+ "javascript": {
22
+ "formatter": {
23
+ "quoteStyle": "double"
24
+ }
25
+ },
26
+ "assist": {
27
+ "enabled": true,
28
+ "actions": {
29
+ "source": {
30
+ "organizeImports": "on"
31
+ }
32
+ }
33
+ }
34
+ }
changelog.md ADDED
@@ -0,0 +1,765 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ Notable changes, grouped by date, most recent first.
4
+
5
+
6
+ ## 2026-09-13
7
+
8
+ - Fixed /status reporting the web search service as healthy while its circuit breaker was open and every search was failing.
9
+ - Added a degraded state and per-engine failure counts to the web search service status on /status.
10
+
11
+ ## 2026-09-11
12
+
13
+ - Added text-to-speech playback that reads answers aloud with a local neural TTS model, falling back to OS voices.
14
+
15
+ ## 2026-09-10
16
+
17
+ - Added a wake lock that keeps the screen awake while a response is generating.
18
+
19
+ ## 2026-09-08
20
+
21
+ - Fixed search history cleanup failing with a TransactionInactiveError.
22
+
23
+ ## 2026-08-31
24
+
25
+ - Dropped image results that come back without a thumbnail URL instead of rendering broken tiles.
26
+
27
+ ## 2026-08-30
28
+
29
+ - Added the server start time to the /status endpoint.
30
+ - Added a token-gated /thumbnail endpoint that loads image result thumbnails lazily through the server.
31
+
32
+ ## 2026-08-28
33
+
34
+ - Made searches degrade to cached and image results when the search engines are down.
35
+
36
+ ## 2026-08-26
37
+
38
+ - Kept the reranker relevance score on results and tagged results in the prompt for the model.
39
+ - Fixed untrusted text in the prompt by labeling all of it, not only page excerpts.
40
+
41
+ ## 2026-08-25
42
+
43
+ - Fixed OpenAI-compatible inference to retry the next model when a stream reports an error.
44
+
45
+ ## 2026-08-23
46
+
47
+ - Security: refused token and access-key hashes with mismatched argon2 parameters.
48
+ - Security: refused an already-rejected token without performing a second argon2 verification.
49
+ - Fixed searches that came back empty with unresponsive engines by retrying them.
50
+
51
+ ## 2026-08-21
52
+
53
+ - Security: rate-limited the access-key validation endpoint.
54
+ - Improved page-content excerpt selection by fusing bi-encoder dense scores with lexical ranking, reading PDF results instead of dropping them, and overlapping splits of long passages.
55
+ - Fixed the configured request timeout to act as an idle timeout on the upstream stream.
56
+
57
+ ## 2026-08-20
58
+
59
+ - Fixed treating a SearXNG response with unresponsive engines as a successful search.
60
+ - Added operational reporting to /status for search timing, reranker effect, dropped thumbnails, inference outcomes, and token and rate-limit rejections.
61
+
62
+ ## 2026-08-19
63
+
64
+ - Security: pinned deepmerge-ts to 8.0.1 via overrides to address a vulnerability.
65
+
66
+ ## 2026-08-17
67
+
68
+ - Fixed duplicate inference when the search form remounts.
69
+
70
+ ## 2026-08-16
71
+
72
+ - Showed the model size while a model is downloading.
73
+ - Reworked the search results hierarchy and the drawer transitions.
74
+ - Fixed the search form submitting while composing text with an IME.
75
+ - Fixed non-Latin follow-up questions being mangled.
76
+
77
+ ## 2026-08-15
78
+
79
+ - Security: guarded thumbnail fetches against SSRF with a size cap, rate-limited requests before token verification, and stopped logging whole error objects on the internal API endpoint.
80
+ - Fixed the image lightbox source label crashing on relative URLs.
81
+ - Fixed failed searches being reported as "no results".
82
+ - Added a value proposition and AI opt-in prompt on first run, shown only after the first search.
83
+
84
+ ## 2026-08-14
85
+
86
+ - Security: bumped nanoid to 3.3.18 to address a vulnerability.
87
+ - Fixed the server logging the search query.
88
+ - Added grounding of AI answers on fetched page content.
89
+
90
+ ## 2026-08-13
91
+
92
+ - Fixed pre-stream inference failures to return 503.
93
+
94
+ ## 2026-08-12
95
+
96
+ - Fixed treating an empty upstream stream as a successful response.
97
+
98
+ ## 2026-08-10
99
+
100
+ - Published the Docker image as `latest` instead of `main`, with provenance, SBOM, and a scan.
101
+
102
+ ## 2026-08-08
103
+
104
+ - Security: bumped postcss to resolve a nanoid vulnerability.
105
+
106
+ ## 2026-08-07
107
+
108
+ - Set LittleLamb 290M as the new default Wllama model.
109
+ - Scaled the default CPU thread count with the machine.
110
+ - Fixed the UI transitioning to the generating state before any content arrived.
111
+
112
+ ## 2026-08-05
113
+
114
+ - Replaced the reranker with a multilingual cross-encoder.
115
+
116
+ ## 2026-07-31
117
+
118
+ - Security: validated and clamped server proxy inputs.
119
+
120
+ ## 2026-07-30
121
+
122
+ - Removed secret build args from the Dockerfile and hardened .dockerignore.
123
+ - Made client configuration runtime-resolved via a new /api/config endpoint.
124
+ - Fixed truncating reranker input by characters instead of tokens.
125
+ - Fixed handling of chunk boundaries in internal API streams.
126
+ - Consolidated duplicate circuit breakers in the web search service.
127
+
128
+ ## 2026-07-29
129
+
130
+ - Fixed /status reporting sessions as zero.
131
+
132
+ ## 2026-07-28
133
+
134
+ - Validated cached model files before reusing them.
135
+ - Security: overrode adm-zip to 0.6.0 to clear GHSA-xcpc-8h2w-3j85.
136
+ - Replaced llama.cpp with ONNX Runtime for reranking.
137
+ - Made the reranker retry on CPU when the GPU provider fails.
138
+ - Made image thumbnails keyboard accessible.
139
+
140
+ ## 2026-07-27
141
+
142
+ - Security: generated the search token with a CSPRNG, widened access-key and search-token hash digests to 32 bytes, and tightened search token file permissions.
143
+
144
+ ## 2026-07-24
145
+
146
+ - Fixed internal API stream errors being swallowed instead of surfaced.
147
+
148
+ ## 2026-07-23
149
+
150
+ - Used SHA-256 for cache keys and included the result limit in the hash input.
151
+ - Added screen-reader landmarks and a live region for streaming responses.
152
+
153
+ ## 2026-07-22
154
+
155
+ - Keyed the rate limiter on the client IP behind a TRUST_PROXY setting to close a rate-limit bypass, and cleaned up verified tokens.
156
+
157
+ ## 2026-07-21
158
+
159
+ - Clamped max_tokens on the /inference proxy.
160
+
161
+ ## 2026-07-13
162
+
163
+ - Removed the searchResultsToConsider setting and hardcoded it to 6.
164
+
165
+ ## 2026-07-08
166
+
167
+ - Replaced the recent-activity list with a contribution heatmap.
168
+ - Added a browser notification when an AI response is ready, which focuses the tab when clicked, with a toggle that properly requests permission.
169
+
170
+ ## 2026-06-27
171
+
172
+ - Removed user-configurable sampling parameters from the settings and UI.
173
+
174
+ ## 2026-05-11
175
+
176
+ - Replaced WebLLM with Wllama WebGPU support and fixed Wllama not receiving search results in its prompt.
177
+
178
+ ## 2026-03-07
179
+
180
+ - Gave each log entry a unique ID so the Logs modal renders them correctly.
181
+
182
+ ## 2026-02-13
183
+
184
+ - Fixed image results and the image carousel not displaying by using the correct thumbnail property.
185
+ - Fixed analytics handling an undefined search source.
186
+ - Added a loading state during access key validation.
187
+
188
+ ## 2026-02-12
189
+
190
+ - Replaced the axios and searxng packages with a native fetch implementation.
191
+ - Added binary-compatibility detection and platform logging to the reranker service.
192
+
193
+ ## 2026-02-03
194
+
195
+ - Added Unicode surrogate sanitization to the reranker service.
196
+
197
+ ## 2026-01-01
198
+
199
+ - Fixed the counting of daily average searches.
200
+ - Added reasoning content streaming support for AI responses.
201
+
202
+ ## 2025-12-01
203
+
204
+ - Ensured the model always has access to search results relevant to the current question, not just the original search query.
205
+
206
+ ## 2025-11-21
207
+
208
+ - Added auto-restart on error for the reranker service.
209
+
210
+ ## 2025-11-08
211
+
212
+ - Fixed stale chat messages carrying over by clearing them before a new search starts.
213
+
214
+ ## 2025-11-07
215
+
216
+ - Prevented duplicate auto-initialization of searches triggered from the URL query.
217
+
218
+ ## 2025-11-06
219
+
220
+ - Recorded searches made from the address bar in the search history.
221
+
222
+ ## 2025-11-05
223
+
224
+ - Implemented a search history system.
225
+
226
+ ## 2025-10-14
227
+
228
+ - Removed the PM2 process manager in favor of a direct npm start command.
229
+
230
+ ## 2025-10-13
231
+
232
+ - Implemented conversation summarization to handle context window overflow.
233
+
234
+ ## 2025-10-07
235
+
236
+ - Added message editing and response regeneration to the chat interface.
237
+
238
+ ## 2025-10-03
239
+
240
+ - Added automatic retry with random model fallback for OpenAI-compatible APIs.
241
+
242
+ ## 2025-09-15
243
+
244
+ - Added model auto-selection for text generation with OpenAI-compatible APIs.
245
+
246
+ ## 2025-09-01
247
+
248
+ - Added blockquote support to the Markdown renderer.
249
+
250
+ ## 2025-08-14
251
+
252
+ - Enabled flash attention in Wllama for faster inference.
253
+
254
+ ## 2025-08-11
255
+
256
+ - Made AI Horde text generation race parallel requests and use the first to finish.
257
+
258
+ ## 2025-08-10
259
+
260
+ - Implemented parallel text generation for AI Horde.
261
+
262
+ ## 2025-08-09
263
+
264
+ - Added an error boundary fallback for code highlighting of unknown languages.
265
+
266
+ ## 2025-07-06
267
+
268
+ - Ensured unique model IDs in the model dropdown.
269
+
270
+ ## 2025-07-02
271
+
272
+ - Downgraded Wllama to a version that can run larger models without running out of memory.
273
+
274
+ ## 2025-06-11
275
+
276
+ - Tracked and prevented duplicate follow-up questions.
277
+ - Interrupted the AI Horde waiting loop when the selected model cannot fulfill the request.
278
+ - Improved text-to-speech preprocessing by removing reasoning blocks and preserving link text.
279
+
280
+ ## 2025-06-10
281
+
282
+ - Kept the system prompt with search results when the context buffer fills and preserved user/assistant alternation when truncating messages.
283
+ - Implemented related search query generation with accumulation of search results.
284
+ - Fixed the key used to deduplicate image results from new search results.
285
+ - Added expandable links so users can see what a link is about while the response generates.
286
+
287
+ ## 2025-06-06
288
+
289
+ - Implemented statistical filtering for search results.
290
+ - Added web search service health checks, exposed the health status on /status, and added a Docker healthcheck.
291
+ - Added log filtering to the logs view.
292
+
293
+ ## 2025-05-22
294
+
295
+ - Improved API auth token handling by moving the token to query parameters with proper status codes and error formatting.
296
+
297
+ ## 2025-05-21
298
+
299
+ - Replaced react-syntax-highlighter with Mantine's Shiki-based code highlight component.
300
+
301
+ ## 2025-05-16
302
+
303
+ - Fell back to "text" syntax highlighting when no language is provided for a code block.
304
+
305
+ ## 2025-05-11
306
+
307
+ - Added an AI response opt-in prompt for first-time users.
308
+
309
+ ## 2025-05-07
310
+
311
+ - Migrated the Docker base image from Alpine to Debian for compatibility with the latest SearXNG image.
312
+
313
+ ## 2025-05-05
314
+
315
+ - Updated the WebLLM package and its model IDs to Qwen3-0.6B.
316
+ - Refactored search caching to use Dexie.js.
317
+
318
+ ## 2025-03-14
319
+
320
+ - Fixed the transition from the "Preparing response" to "Generating response" state when using Wllama.
321
+
322
+ ## 2025-03-05
323
+
324
+ - Added a loader indicating that the AI thinking process is running.
325
+
326
+ ## 2025-03-02
327
+
328
+ - Fixed an issue preventing typing in the input field from the Follow-up Questions area.
329
+
330
+ ## 2025-02-22
331
+
332
+ - Fixed image results occasionally appearing duplicated.
333
+
334
+ ## 2025-02-21
335
+
336
+ - Added an AI reasoning section driven by custom markers in the response.
337
+
338
+ ## 2025-02-20
339
+
340
+ - Improved search result quality by removing less relevant matches.
341
+ - Fixed image re-ranking returning the unranked list and added a one-second timeout when fetching image thumbnails.
342
+
343
+ ## 2025-02-11
344
+
345
+ - Changed the default models to 1B-parameter models.
346
+
347
+ ## 2025-02-09
348
+
349
+ - Replaced the "openai" package with "@ai-sdk/openai-compatible" for OpenAI-compatible inference.
350
+
351
+ ## 2025-02-08
352
+
353
+ - Displayed the username and kudos balance of the configured AI Horde API key.
354
+
355
+ ## 2025-02-02
356
+
357
+ - Reduced the Docker image size by switching to a multi-stage build.
358
+
359
+ ## 2025-01-25
360
+
361
+ - Switched the reranker to a faster model running on a single thread.
362
+ - Replaced the node-llama-cpp dependency with a llama-server instance running inside the container.
363
+
364
+ ## 2025-01-24
365
+
366
+ - Allowed setting allowed-hosts via environment variable.
367
+
368
+ ## 2025-01-23
369
+
370
+ - Opened links in AI responses in new tabs.
371
+ - Improved the read-aloud feature by cleaning the text before invoking speech synthesis.
372
+ - Allowed manually entering a model name when the API returns an empty model list.
373
+
374
+ ## 2025-01-17
375
+
376
+ - Streamed responses from AI Horde models.
377
+
378
+ ## 2025-01-05
379
+
380
+ - Added an AI Horde model selection dropdown.
381
+
382
+ ## 2025-01-03
383
+
384
+ - Fixed unhandled errors in the read-aloud (speak response) feature.
385
+ - Allowed users to supply their own AI Horde API key for faster responses.
386
+
387
+ ## 2024-12-28
388
+
389
+ - Allowed manually setting the model identifier when the /models endpoint is unavailable.
390
+
391
+ ## 2024-12-03
392
+
393
+ - Removed the custom Wllama cache manager now that iOS 18 caches correctly by default.
394
+
395
+ ## 2024-12-01
396
+
397
+ - Increased the default context size to 4096 tokens and updated Wllama cache settings.
398
+ - Improved markdown rendering in AI responses with copyable code blocks, table styling, and correct nested list items.
399
+ - Added error and loading handling to the search results section.
400
+
401
+ ## 2024-11-26
402
+
403
+ - Added voice selection for AI responses.
404
+
405
+ ## 2024-11-25
406
+
407
+ - Added speech synthesis for reading AI responses aloud.
408
+
409
+ ## 2024-11-24
410
+
411
+ - Allowed users to customize AI inference parameters such as temperature, top-p, frequency penalty, and presence penalty.
412
+
413
+ ## 2024-11-23
414
+
415
+ - Filtered out invalid image results to avoid broken entries in image search.
416
+ - Decoupled text and image search so results load independently, raising the rate limit to match.
417
+
418
+ ## 2024-11-20
419
+
420
+ - Fixed the model selector resetting the chosen model when re-opening the menu on Remote Server (API).
421
+
422
+ ## 2024-11-14
423
+
424
+ - Reduced Wllama memory usage by switching the cache from f16 to q8_0.
425
+
426
+ ## 2024-11-13
427
+
428
+ - Made searches faster by processing SearXNG results concurrently.
429
+
430
+ ## 2024-11-09
431
+
432
+ - Added AI Horde as an additional inference provider alongside the existing browser-based and OpenAI options.
433
+
434
+ ## 2024-11-07
435
+
436
+ - Added a Search Settings section to the menu, including a new Search Results Limit setting.
437
+
438
+ ## 2024-11-06
439
+
440
+ - Capped the maximum number of search results server-side while keeping the client-side setting configurable.
441
+
442
+ ## 2024-11-03
443
+
444
+ - Improved error handling in the AI Settings form when Remote Server (API) is selected.
445
+ - Allowed copying messages from the Follow-up Questions area.
446
+ - Added a button to regenerate the response.
447
+
448
+ ## 2024-11-01
449
+
450
+ - Improved image search quality and speed by downloading only images that pass similarity checks.
451
+
452
+ ## 2024-10-30
453
+
454
+ - Fixed the Interrupt Generation behavior.
455
+ - Added an option to turn AI response auto-scrolling on and off.
456
+
457
+ ## 2024-10-28
458
+
459
+ - Stopped fetching the model list until the user sets an API Base URL.
460
+ - Displayed a suggested CPU thread count for Wllama.
461
+
462
+ ## 2024-10-26
463
+
464
+ - Fixed the categories queried from SearXNG, avoiding errors from unused categories.
465
+ - Displayed the model size alongside the model download progress bar.
466
+
467
+ ## 2024-10-25
468
+
469
+ - Displayed Wllama model sizes in megabytes, matching the WebLLM models.
470
+ - Added average searches per session statistics.
471
+
472
+ ## 2024-10-24
473
+
474
+ - Renamed "Inference Type" to "AI Processing Location" for clarity.
475
+ - Made the Docker image runnable on both ARM64 and AMD64 platforms.
476
+ - Displayed an error message under the API Model field when listing models from the API fails.
477
+ - Added access key persistence with a configurable timeout, including an ACCESS_KEY_TIMEOUT_HOURS build argument.
478
+
479
+ ## 2024-10-23
480
+
481
+ - Improved the re-ranking of search results.
482
+
483
+ ## 2024-10-22
484
+
485
+ - Fixed the response state not updating after approving an AI model download.
486
+
487
+ ## 2024-10-21
488
+
489
+ - Required user permission before downloading AI models, for transparency and data-usage control.
490
+ - Included search result URLs in Wllama prompts by default.
491
+ - Implemented dynamic context window management using token counting for chat responses.
492
+
493
+ ## 2024-10-19
494
+
495
+ - Passed only the last three messages when continuing a Follow-up Questions conversation, to fit small context limits.
496
+
497
+ ## 2024-10-18
498
+
499
+ - Removed search results whose snippets start with "[data:image".
500
+
501
+ ## 2024-10-16
502
+
503
+ - Fixed link snippets overflowing their container when content contains extremely long strings.
504
+ - Added a button to restore the default AI instructions.
505
+
506
+ ## 2024-10-13
507
+
508
+ - Allowed reading the default inference type from the DEFAULT_INFERENCE_TYPE environment variable.
509
+
510
+ ## 2024-10-11
511
+
512
+ - Added automatic selection of the first available model when configuring an OpenAI-compatible API.
513
+
514
+ ## 2024-10-10
515
+
516
+ - Added support for asking follow-up questions to the AI response.
517
+
518
+ ## 2024-10-08
519
+
520
+ - Fixed OpenAI-compatible inference for models that do not accept a System role.
521
+ - Added support for an internal OpenAI-compatible API as a text-generation backend.
522
+
523
+ ## 2024-10-05
524
+
525
+ - Added support for customizing the default models via the `.env` file.
526
+
527
+ ## 2024-10-04
528
+
529
+ - Added an access page that gates the app when `ACCESS_KEYS` is configured in the `.env` file.
530
+
531
+ ## 2024-09-29
532
+
533
+ - Replaced the image zoom library with `yet-another-react-lightbox` to improve previewing of image results.
534
+ - Made the model selection list searchable to handle long model lists.
535
+
536
+ ## 2024-09-23
537
+
538
+ - Added sending the search query to the parent window via postMessage when navigating from an embedded instance.
539
+
540
+ ## 2024-09-21
541
+
542
+ - Made Qwen 0.5B the default Wllama model.
543
+ - Added a setting to enable or disable submitting a search with the Enter key.
544
+ - Added a dark mode toggle to the interface settings.
545
+
546
+ ## 2024-09-18
547
+
548
+ - Fixed settings to always fall back to a valid model when a previously selected model is no longer available.
549
+ - Added an option to show the full AI response without a scroll bar.
550
+
551
+ ## 2024-09-16
552
+
553
+ - Integrated an OpenAI-compatible API for text generation.
554
+
555
+ ## 2024-09-15
556
+
557
+ - Replaced the image results scroll area with a carousel.
558
+ - Limited text results only after processing them so more results are available for reranking.
559
+ - Displayed the AI response in a card and added a button to copy it.
560
+ - Added recommended-model indicators for WebGPU and CPU in the model selection.
561
+
562
+ ## 2024-09-14
563
+
564
+ - Switched production server process management to PM2.
565
+ - Migrated the UI components from React Suite to Mantine.
566
+
567
+ ## 2024-09-09
568
+
569
+ - Kept the already-generated part of the AI response visible by showing failure messages below it instead of hiding the response.
570
+ - Added a logs viewer accessible from a menu button.
571
+
572
+ ## 2024-09-08
573
+
574
+ - Fixed duplicate source URLs for image results returned by SearXNG.
575
+ - Added a setting for customizing the AI instructions.
576
+
577
+ ## 2024-09-07
578
+
579
+ - Added image search with zoomable image results.
580
+
581
+ ## 2024-09-05
582
+
583
+ - Added a setting to select the model used when running inference on CPU.
584
+
585
+ ## 2024-09-04
586
+
587
+ - Fixed the CPU thread count saved through the settings menu being stored with the wrong type.
588
+
589
+ ## 2024-09-01
590
+
591
+ - Added a configurable background image.
592
+
593
+ ## 2024-08-30
594
+
595
+ - Improved Markdown rendering and syntax highlighting, including fixing code blocks overflowing the container width.
596
+
597
+ ## 2024-08-29
598
+
599
+ - Added IndexedDB caching for search results.
600
+
601
+ ## 2024-08-25
602
+
603
+ - Avoided showing more than one search result from the same hostname.
604
+ - Added a setting to choose the AI model used when WebGPU is enabled.
605
+ - Added a button to interrupt response generation.
606
+
607
+ ## 2024-08-24
608
+
609
+ - Removed the RatchetML inference backend.
610
+ - Fixed Wllama models not loading from cache on iPads by using a custom cache manager.
611
+ - Allowed multi-threading for Wllama on mobile devices.
612
+ - Added a progress bar when loading the AI model.
613
+
614
+ ## 2024-08-22
615
+
616
+ - Revamped the UI using React Suite components.
617
+
618
+ ## 2024-07-26
619
+
620
+ - Replaced the built-in search functionality with the SearXNG library.
621
+
622
+ ## 2024-07-20
623
+
624
+ - Renamed settings to menu and added an option to reset settings and delete all cached files to free up space.
625
+
626
+ ## 2024-07-13
627
+
628
+ - Allowed Wllama models to run when the browser reports no internet access.
629
+
630
+ ## 2024-06-28
631
+
632
+ - Replaced react-router-dom with the wouter library to reduce memory usage.
633
+
634
+ ## 2024-06-27
635
+
636
+ - Started responding immediately when the number of search results to consider is zero.
637
+
638
+ ## 2024-06-23
639
+
640
+ - Added client-side caching of search results during a session.
641
+
642
+ ## 2024-06-20
643
+
644
+ - Added a setting for the number of search results to consider when generating AI responses.
645
+
646
+ ## 2024-06-14
647
+
648
+ - Restricted RatchetML inference to the Safari browser.
649
+ - Added a clear button shown when the search field is not empty.
650
+
651
+ ## 2024-06-12
652
+
653
+ - Added a score threshold to avoid showing irrelevant search results.
654
+
655
+ ## 2024-06-09
656
+
657
+ - Interrupted text generation when the user runs another search while one is still in progress.
658
+ - Added client-side routing so new searches no longer reload the page.
659
+
660
+ ## 2024-06-05
661
+
662
+ - Added embedding-based reranking of search results using node-llama-cpp.
663
+
664
+ ## 2024-05-21
665
+
666
+ - Started loading the AI model while web search results were being fetched, speeding up responses.
667
+ - Removed the Summarize Links feature.
668
+
669
+ ## 2024-05-18
670
+
671
+ - Replaced the compression middleware with http-compression to support HTTP/2.
672
+
673
+ ## 2024-05-17
674
+
675
+ - Added a setting for the number of CPU threads to use.
676
+
677
+ ## 2024-05-11
678
+
679
+ - Switched to single-threaded Wllama on mobile devices due to low available memory.
680
+
681
+ ## 2024-05-10
682
+
683
+ - Used sharded GGUF models on desktop to stay within Safari's cache size limit.
684
+ - Displayed the model loading progress percentage when using Wllama.
685
+
686
+ ## 2024-05-08
687
+
688
+ - Increased the variety of query suggestions by fetching new ones at runtime instead of defining them at build time.
689
+
690
+ ## 2024-05-06
691
+
692
+ - Improved the link summarization feature by using Jina AI Reader.
693
+
694
+ ## 2024-05-03
695
+
696
+ - Added RatchetML as a WebGPU inference backend alongside Web-LLM.
697
+
698
+ ## 2024-05-01
699
+
700
+ - Moved search result ranking to the backend so mobile devices benefit from better results.
701
+
702
+ ## 2024-04-27
703
+
704
+ - Added a rate limiter and token verification to prevent abuse of the search endpoint.
705
+ - Added a /status endpoint for monitoring the server.
706
+
707
+ ## 2024-04-20
708
+
709
+ - Added Llama 3 8B as the larger model for Web-LLM.
710
+
711
+ ## 2024-04-17
712
+
713
+ - Replaced the Express server with Vite's preview server.
714
+
715
+ ## 2024-03-28
716
+
717
+ - Added a keyword-based re-search when the original query returns no results.
718
+
719
+ ## 2024-03-23
720
+
721
+ - Added suggested queries to the search form.
722
+
723
+ ## 2024-03-22
724
+
725
+ - Displayed the domain alongside each search result link to increase click confidence.
726
+
727
+ ## 2024-03-16
728
+
729
+ - Added Wllama as an alternative in-browser inference library.
730
+
731
+ ## 2024-03-12
732
+
733
+ - Added re-ranking of search results before passing them to the language model.
734
+
735
+ ## 2024-01-09
736
+
737
+ - Added three model sizes to fit all devices when running on transformers.js.
738
+
739
+ ## 2023-12-31
740
+
741
+ - Added support for a larger model option when using transformers.js.
742
+
743
+ ## 2023-12-23
744
+
745
+ - Switched from a text-to-text-generation model to a text-generation model.
746
+
747
+ ## 2023-11-02
748
+
749
+ - Started publishing the Docker image to GitHub Packages.
750
+
751
+ ## 2023-10-23
752
+
753
+ - Added a setting for disabling AI responses.
754
+
755
+ ## 2023-10-22
756
+
757
+ - Adopted SearXNG as the metasearch engine.
758
+
759
+ ## 2023-10-21
760
+
761
+ - Added a settings menu with options to toggle links summarization and use of a larger model.
762
+
763
+ ## 2023-10-14
764
+
765
+ - Started MiniSearch as a self-hosted metasearch app with in-browser AI-generated answers.
client/components/AiResponse/AiModelDownloadAllowanceContent.tsx ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Alert, Button, Group, Text } from "@mantine/core";
2
+ import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
3
+ import { usePubSub } from "create-pubsub/react";
4
+ import { useState } from "react";
5
+ import { addLogEntry } from "@/modules/logEntries";
6
+ import { settingsPubSub } from "@/modules/pubSub";
7
+
8
+ export default function AiModelDownloadAllowanceContent() {
9
+ const [settings, setSettings] = usePubSub(settingsPubSub);
10
+ const [hasDeniedDownload, setDeniedDownload] = useState(false);
11
+
12
+ const handleAccept = () => {
13
+ setSettings({
14
+ ...settings,
15
+ allowAiModelDownload: true,
16
+ });
17
+ addLogEntry("User allowed the AI model download");
18
+ };
19
+
20
+ const handleDecline = () => {
21
+ setDeniedDownload(true);
22
+ addLogEntry("User denied the AI model download");
23
+ };
24
+
25
+ return hasDeniedDownload ? null : (
26
+ <Alert
27
+ variant="light"
28
+ color="blue"
29
+ title="Allow AI model download?"
30
+ icon={<IconInfoCircle />}
31
+ >
32
+ <Text size="sm" mb="md">
33
+ To obtain AI responses, a language model needs to be downloaded to your
34
+ browser. Enabling this option lets the app store it and load it
35
+ instantly on subsequent uses.
36
+ </Text>
37
+ <Text size="sm" mb="md">
38
+ Please note that the download size ranges from 100 MB to 4 GB, depending
39
+ on the model you select in the Menu, so it's best to avoid using mobile
40
+ data for this.
41
+ </Text>
42
+ <Group justify="flex-end" mt="md">
43
+ <Button
44
+ variant="subtle"
45
+ color="gray"
46
+ leftSection={<IconX size="1rem" />}
47
+ onClick={handleDecline}
48
+ size="xs"
49
+ >
50
+ Not now
51
+ </Button>
52
+ <Button
53
+ leftSection={<IconCheck size="1rem" />}
54
+ onClick={handleAccept}
55
+ size="xs"
56
+ >
57
+ Allow download
58
+ </Button>
59
+ </Group>
60
+ </Alert>
61
+ );
62
+ }
client/components/AiResponse/AiResponseContent.test.tsx ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MantineProvider } from "@mantine/core";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { usePubSub } from "create-pubsub/react";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import AiResponseContent from "./AiResponseContent";
6
+
7
+ vi.mock("create-pubsub/react", () => ({
8
+ usePubSub: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("@/modules/textGeneration", () => ({
12
+ searchAndRespond: vi.fn(),
13
+ }));
14
+
15
+ vi.mock("@/modules/textToSpeech", () => ({
16
+ speak: vi.fn(),
17
+ }));
18
+
19
+ vi.mock("./FormattedMarkdown", () => ({
20
+ default: ({ children }: { children: string }) => (
21
+ <div data-testid="formatted-markdown">{children}</div>
22
+ ),
23
+ }));
24
+
25
+ function renderContent(textGenerationState: string) {
26
+ vi.mocked(usePubSub)
27
+ .mockReturnValueOnce([{ enableAiResponseScrolling: false }, vi.fn()])
28
+ .mockReturnValueOnce(["idle", vi.fn()]);
29
+
30
+ return render(
31
+ <MantineProvider>
32
+ <AiResponseContent
33
+ textGenerationState={textGenerationState}
34
+ response="Hello world"
35
+ setTextGenerationState={vi.fn()}
36
+ />
37
+ </MantineProvider>,
38
+ );
39
+ }
40
+
41
+ describe("AiResponseContent", () => {
42
+ beforeEach(() => {
43
+ vi.clearAllMocks();
44
+ });
45
+
46
+ it("wraps the response body in a polite live region", () => {
47
+ renderContent("generating");
48
+
49
+ const liveRegion = screen
50
+ .getByTestId("formatted-markdown")
51
+ .closest("[aria-live]");
52
+ expect(liveRegion).toHaveAttribute("aria-live", "polite");
53
+ });
54
+
55
+ it("marks the live region busy while generating", () => {
56
+ renderContent("generating");
57
+
58
+ const liveRegion = screen
59
+ .getByTestId("formatted-markdown")
60
+ .closest("[aria-live]");
61
+ expect(liveRegion).toHaveAttribute("aria-busy", "true");
62
+ });
63
+
64
+ it("clears the busy flag once generation is no longer in progress", () => {
65
+ renderContent("completed");
66
+
67
+ const liveRegion = screen
68
+ .getByTestId("formatted-markdown")
69
+ .closest("[aria-live]");
70
+ expect(liveRegion).toHaveAttribute("aria-busy", "false");
71
+ });
72
+ });
client/components/AiResponse/AiResponseContent.tsx ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ ActionIcon,
3
+ Alert,
4
+ Badge,
5
+ Box,
6
+ Card,
7
+ Group,
8
+ ScrollArea,
9
+ Text,
10
+ Tooltip,
11
+ } from "@mantine/core";
12
+ import {
13
+ IconArrowsMaximize,
14
+ IconArrowsMinimize,
15
+ IconHandStop,
16
+ IconInfoCircle,
17
+ IconRefresh,
18
+ IconVolume2,
19
+ } from "@tabler/icons-react";
20
+ import type { PublishFunction } from "create-pubsub";
21
+ import { usePubSub } from "create-pubsub/react";
22
+ import { type ReactNode, useMemo } from "react";
23
+ import { settingsPubSub, textToSpeechStatePubSub } from "@/modules/pubSub";
24
+ import { searchAndRespond } from "@/modules/textGeneration";
25
+ import { speak } from "@/modules/textToSpeech";
26
+ import CopyIconButton from "./CopyIconButton";
27
+ import FormattedMarkdown from "./FormattedMarkdown";
28
+
29
+ export default function AiResponseContent({
30
+ textGenerationState,
31
+ response,
32
+ setTextGenerationState,
33
+ }: {
34
+ textGenerationState: string;
35
+ response: string;
36
+ setTextGenerationState: PublishFunction<
37
+ | "failed"
38
+ | "awaitingSearchResults"
39
+ | "preparingToGenerate"
40
+ | "idle"
41
+ | "loadingModel"
42
+ | "generating"
43
+ | "interrupted"
44
+ | "completed"
45
+ >;
46
+ }) {
47
+ const [settings, setSettings] = usePubSub(settingsPubSub);
48
+ const [textToSpeechState] = usePubSub(textToSpeechStatePubSub);
49
+ const isSpeaking = textToSpeechState === "speaking";
50
+ const isGenerating = textGenerationState === "generating";
51
+
52
+ const ConditionalScrollArea = useMemo(
53
+ () =>
54
+ ({ children }: { children: ReactNode }) => {
55
+ return settings.enableAiResponseScrolling ? (
56
+ <ScrollArea.Autosize
57
+ mah={300}
58
+ type="auto"
59
+ scrollbars="y"
60
+ offsetScrollbars
61
+ >
62
+ {children}
63
+ </ScrollArea.Autosize>
64
+ ) : (
65
+ <Box>{children}</Box>
66
+ );
67
+ },
68
+ [settings.enableAiResponseScrolling],
69
+ );
70
+
71
+ return (
72
+ <Card withBorder shadow="sm" radius="md">
73
+ <Card.Section withBorder inheritPadding py="xs">
74
+ <Group justify="space-between">
75
+ <Group gap="xs" align="center">
76
+ <Text fw={500}>
77
+ {textGenerationState === "generating"
78
+ ? "Generating AI Response..."
79
+ : "AI Response"}
80
+ </Text>
81
+ {textGenerationState === "interrupted" && (
82
+ <Badge variant="light" color="yellow" size="xs">
83
+ Interrupted
84
+ </Badge>
85
+ )}
86
+ </Group>
87
+ <Group gap="xs" align="center">
88
+ {textGenerationState === "generating" ? (
89
+ <Tooltip label="Interrupt generation">
90
+ <ActionIcon
91
+ onClick={() => setTextGenerationState("interrupted")}
92
+ variant="subtle"
93
+ color="gray"
94
+ >
95
+ <IconHandStop size={16} />
96
+ </ActionIcon>
97
+ </Tooltip>
98
+ ) : (
99
+ <Tooltip label="Regenerate response">
100
+ <ActionIcon
101
+ onClick={() => searchAndRespond()}
102
+ variant="subtle"
103
+ color="gray"
104
+ >
105
+ <IconRefresh size={16} />
106
+ </ActionIcon>
107
+ </Tooltip>
108
+ )}
109
+ <Tooltip
110
+ label={isSpeaking ? "Stop speaking" : "Listen to response"}
111
+ >
112
+ <ActionIcon
113
+ onClick={() => speak(response)}
114
+ variant="subtle"
115
+ color={isSpeaking ? "blue" : "gray"}
116
+ >
117
+ <IconVolume2 size={16} />
118
+ </ActionIcon>
119
+ </Tooltip>
120
+ {settings.enableAiResponseScrolling ? (
121
+ <Tooltip label="Show full response without scroll bar">
122
+ <ActionIcon
123
+ onClick={() => {
124
+ setSettings({
125
+ ...settings,
126
+ enableAiResponseScrolling: false,
127
+ });
128
+ }}
129
+ variant="subtle"
130
+ color="gray"
131
+ >
132
+ <IconArrowsMaximize size={16} />
133
+ </ActionIcon>
134
+ </Tooltip>
135
+ ) : (
136
+ <Tooltip label="Enable scroll bar">
137
+ <ActionIcon
138
+ onClick={() => {
139
+ setSettings({
140
+ ...settings,
141
+ enableAiResponseScrolling: true,
142
+ });
143
+ }}
144
+ variant="subtle"
145
+ color="gray"
146
+ >
147
+ <IconArrowsMinimize size={16} />
148
+ </ActionIcon>
149
+ </Tooltip>
150
+ )}
151
+ <CopyIconButton value={response} tooltipLabel="Copy response" />
152
+ </Group>
153
+ </Group>
154
+ </Card.Section>
155
+ <Card.Section withBorder>
156
+ <ConditionalScrollArea>
157
+ <Box aria-live="polite" aria-busy={isGenerating}>
158
+ <FormattedMarkdown>{response}</FormattedMarkdown>
159
+ </Box>
160
+ </ConditionalScrollArea>
161
+ {textGenerationState === "failed" && (
162
+ <Alert
163
+ variant="light"
164
+ color="yellow"
165
+ title="Failed to generate response"
166
+ icon={<IconInfoCircle />}
167
+ >
168
+ Could not generate response. Please try refreshing the page.
169
+ </Alert>
170
+ )}
171
+ </Card.Section>
172
+ </Card>
173
+ );
174
+ }
client/components/AiResponse/AiResponseSection.tsx ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CodeHighlightAdapterProvider } from "@mantine/code-highlight";
2
+ import { usePubSub } from "create-pubsub/react";
3
+ import { useMemo } from "react";
4
+ import { useScreenWakeLock } from "@/hooks/useScreenWakeLock";
5
+ import {
6
+ chatMessagesPubSub,
7
+ isRestoringFromHistoryPubSub,
8
+ modelLoadingProgressPubSub,
9
+ modelSizeInMegabytesPubSub,
10
+ queryPubSub,
11
+ responsePubSub,
12
+ settingsPubSub,
13
+ textGenerationStatePubSub,
14
+ } from "@/modules/pubSub";
15
+ import { shikiAdapter } from "@/modules/shiki";
16
+ import type { TextGenerationState } from "@/modules/types";
17
+ import "@mantine/code-highlight/styles.css";
18
+ import AiModelDownloadAllowanceContent from "./AiModelDownloadAllowanceContent";
19
+ import AiResponseContent from "./AiResponseContent";
20
+ import ChatInterface from "./ChatInterface";
21
+ import LoadingModelContent from "./LoadingModelContent";
22
+ import PreparingContent from "./PreparingContent";
23
+
24
+ const statesKeepingTheScreenAwake: TextGenerationState[] = [
25
+ "loadingModel",
26
+ "awaitingSearchResults",
27
+ "preparingToGenerate",
28
+ "generating",
29
+ ];
30
+
31
+ export default function AiResponseSection() {
32
+ const [query] = usePubSub(queryPubSub);
33
+ const [response] = usePubSub(responsePubSub);
34
+ const [textGenerationState, setTextGenerationState] = usePubSub(
35
+ textGenerationStatePubSub,
36
+ );
37
+ const [modelLoadingProgress] = usePubSub(modelLoadingProgressPubSub);
38
+ const [settings] = usePubSub(settingsPubSub);
39
+ const [modelSizeInMegabytes] = usePubSub(modelSizeInMegabytesPubSub);
40
+ const [chatMessages] = usePubSub(chatMessagesPubSub);
41
+ const [isRestoringFromHistory] = usePubSub(isRestoringFromHistoryPubSub);
42
+
43
+ useScreenWakeLock(statesKeepingTheScreenAwake.includes(textGenerationState));
44
+
45
+ return useMemo(() => {
46
+ if (!settings.enableAiResponse || textGenerationState === "idle") {
47
+ return null;
48
+ }
49
+
50
+ const generatingStates = [
51
+ "generating",
52
+ "interrupted",
53
+ "completed",
54
+ "failed",
55
+ ];
56
+ if (generatingStates.includes(textGenerationState)) {
57
+ return (
58
+ <CodeHighlightAdapterProvider adapter={shikiAdapter}>
59
+ <AiResponseContent
60
+ textGenerationState={textGenerationState}
61
+ response={response}
62
+ setTextGenerationState={setTextGenerationState}
63
+ />
64
+
65
+ {textGenerationState === "completed" && (
66
+ <ChatInterface
67
+ initialQuery={query}
68
+ initialResponse={response}
69
+ initialMessages={
70
+ chatMessages.length > 0 ? chatMessages : undefined
71
+ }
72
+ suppressInitialFollowUp={isRestoringFromHistory}
73
+ />
74
+ )}
75
+ </CodeHighlightAdapterProvider>
76
+ );
77
+ }
78
+
79
+ if (textGenerationState === "loadingModel") {
80
+ return (
81
+ <LoadingModelContent
82
+ modelLoadingProgress={modelLoadingProgress}
83
+ modelSizeInMegabytes={modelSizeInMegabytes}
84
+ />
85
+ );
86
+ }
87
+
88
+ if (textGenerationState === "preparingToGenerate") {
89
+ return <PreparingContent textGenerationState={textGenerationState} />;
90
+ }
91
+
92
+ if (textGenerationState === "awaitingSearchResults") {
93
+ return <PreparingContent textGenerationState={textGenerationState} />;
94
+ }
95
+
96
+ if (textGenerationState === "awaitingModelDownloadAllowance") {
97
+ return <AiModelDownloadAllowanceContent />;
98
+ }
99
+
100
+ return null;
101
+ }, [
102
+ settings.enableAiResponse,
103
+ textGenerationState,
104
+ response,
105
+ query,
106
+ chatMessages,
107
+ modelLoadingProgress,
108
+ modelSizeInMegabytes,
109
+ setTextGenerationState,
110
+ isRestoringFromHistory,
111
+ ]);
112
+ }
client/components/AiResponse/ChatHeader.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Group, Text } from "@mantine/core";
2
+ import type { ChatMessage } from "@/modules/types";
3
+ import CopyIconButton from "./CopyIconButton";
4
+
5
+ interface ChatHeaderProps {
6
+ messages: ChatMessage[];
7
+ }
8
+
9
+ function ChatHeader({ messages }: ChatHeaderProps) {
10
+ const getChatContent = () => {
11
+ return messages
12
+ .slice(2)
13
+ .map(
14
+ (msg, index) =>
15
+ `${index + 1}. ${msg.role?.toUpperCase()}\n\n${msg.content}`,
16
+ )
17
+ .join("\n\n");
18
+ };
19
+
20
+ return (
21
+ <Group justify="space-between">
22
+ <Text fw={500}>Follow-up questions</Text>
23
+ {messages.length > 2 && (
24
+ <CopyIconButton
25
+ value={getChatContent()}
26
+ tooltipLabel="Copy conversation"
27
+ />
28
+ )}
29
+ </Group>
30
+ );
31
+ }
32
+
33
+ export default ChatHeader;
client/components/AiResponse/ChatInputArea.tsx ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button, Group, Textarea } from "@mantine/core";
2
+ import { IconSend } from "@tabler/icons-react";
3
+ import { usePubSub } from "create-pubsub/react";
4
+ import {
5
+ chatGenerationStatePubSub,
6
+ chatInputPubSub,
7
+ followUpQuestionPubSub,
8
+ isRestoringFromHistoryPubSub,
9
+ suppressNextFollowUpPubSub,
10
+ } from "@/modules/pubSub";
11
+
12
+ interface ChatInputAreaProps {
13
+ onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
14
+ handleSend: (textToSend?: string) => void;
15
+ }
16
+
17
+ function ChatInputArea({ onKeyDown, handleSend }: ChatInputAreaProps) {
18
+ const [input, setInput] = usePubSub(chatInputPubSub);
19
+ const [generationState] = usePubSub(chatGenerationStatePubSub);
20
+ const [followUpQuestion] = usePubSub(followUpQuestionPubSub);
21
+ const [isRestoringFromHistory] = usePubSub(isRestoringFromHistoryPubSub);
22
+ const [suppressNextFollowUp] = usePubSub(suppressNextFollowUpPubSub);
23
+
24
+ const isGenerating =
25
+ generationState.isGeneratingResponse &&
26
+ !generationState.isGeneratingFollowUpQuestion;
27
+
28
+ const defaultPlaceholder = "Anything else you would like to know?";
29
+ const placeholder =
30
+ isRestoringFromHistory || suppressNextFollowUp
31
+ ? defaultPlaceholder
32
+ : followUpQuestion || defaultPlaceholder;
33
+
34
+ const onChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
35
+ setInput(event.target.value);
36
+ };
37
+ const handleKeyDownWithPlaceholder = (
38
+ event: React.KeyboardEvent<HTMLTextAreaElement>,
39
+ ) => {
40
+ // Same IME guard as handleEnterKeyDown: the Enter that confirms a
41
+ // composition candidate must not fire the follow-up question while the
42
+ // composition text has not reached the input value yet.
43
+ const isComposing = event.nativeEvent.isComposing || event.keyCode === 229;
44
+
45
+ if (
46
+ input.trim() === "" &&
47
+ followUpQuestion &&
48
+ !isRestoringFromHistory &&
49
+ !suppressNextFollowUp &&
50
+ !isComposing
51
+ ) {
52
+ if (event.key === "Enter" && !event.shiftKey) {
53
+ event.preventDefault();
54
+ handleSend(followUpQuestion);
55
+ return;
56
+ }
57
+ }
58
+
59
+ onKeyDown(event);
60
+ };
61
+
62
+ const handleSendWithPlaceholder = () => {
63
+ if (
64
+ input.trim() === "" &&
65
+ followUpQuestion &&
66
+ !isRestoringFromHistory &&
67
+ !suppressNextFollowUp
68
+ ) {
69
+ handleSend(followUpQuestion);
70
+ } else {
71
+ handleSend();
72
+ }
73
+ };
74
+
75
+ return (
76
+ <Group align="flex-end" style={{ position: "relative" }}>
77
+ <Textarea
78
+ size="sm"
79
+ aria-label="Chat input"
80
+ placeholder={placeholder}
81
+ value={input}
82
+ onChange={onChange}
83
+ onKeyDown={handleKeyDownWithPlaceholder}
84
+ autosize
85
+ minRows={1}
86
+ maxRows={8}
87
+ style={{ flexGrow: 1, paddingRight: "50px" }}
88
+ disabled={isGenerating}
89
+ />
90
+ <Button
91
+ aria-label="Send message"
92
+ size="sm"
93
+ variant="default"
94
+ onClick={handleSendWithPlaceholder}
95
+ loading={isGenerating}
96
+ style={{
97
+ height: "100%",
98
+ position: "absolute",
99
+ right: 0,
100
+ top: 0,
101
+ bottom: 0,
102
+ borderTopLeftRadius: 0,
103
+ borderBottomLeftRadius: 0,
104
+ }}
105
+ >
106
+ <IconSend size={16} />
107
+ </Button>
108
+ </Group>
109
+ );
110
+ }
111
+
112
+ export default ChatInputArea;
client/components/AiResponse/ChatInterface.test.tsx ADDED
@@ -0,0 +1,651 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MantineProvider } from "@mantine/core";
2
+ import { act, render } from "@testing-library/react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ chatGenerationStatePubSub,
6
+ chatInputPubSub,
7
+ followUpQuestionPubSub,
8
+ suppressNextFollowUpPubSub,
9
+ } from "@/modules/pubSub";
10
+ import type { ChatMessage } from "@/modules/types";
11
+ import ChatInterface from "./ChatInterface";
12
+
13
+ const [updateChatGenerationState, , getChatGenerationState] =
14
+ chatGenerationStatePubSub;
15
+ const [updateChatInput] = chatInputPubSub;
16
+ const [updateFollowUpQuestion, , getFollowUpQuestion] = followUpQuestionPubSub;
17
+ const [updateSuppressNextFollowUp] = suppressNextFollowUpPubSub;
18
+
19
+ vi.mock("@/modules/textGeneration", () => ({
20
+ generateChatResponse: vi.fn(),
21
+ }));
22
+
23
+ vi.mock("@/modules/followUpQuestions", () => ({
24
+ generateFollowUpQuestion: vi.fn(),
25
+ }));
26
+
27
+ vi.mock("@/modules/chatHelpers", () => ({
28
+ persistChatMessages: vi.fn(() => Promise.resolve()),
29
+ runFollowUpSearch: vi.fn(() => Promise.resolve()),
30
+ }));
31
+
32
+ vi.mock("@/modules/notifications", () => ({
33
+ showAiCompleteNotification: vi.fn(),
34
+ }));
35
+
36
+ vi.mock("@/modules/logEntries", () => ({ addLogEntry: vi.fn() }));
37
+
38
+ vi.mock("./ChatHeader", () => ({ default: () => null }));
39
+
40
+ let sendMessage: (textToSend?: string) => Promise<void>;
41
+ let regenerateResponse: () => Promise<void>;
42
+ let messageListMessages: ChatMessage[] = [];
43
+ let messageListIsGenerating = false;
44
+
45
+ vi.mock("./ChatInputArea", () => ({
46
+ default: (props: { handleSend: (textToSend?: string) => Promise<void> }) => {
47
+ sendMessage = props.handleSend;
48
+ return null;
49
+ },
50
+ }));
51
+
52
+ vi.mock("./MessageList", () => ({
53
+ default: (props: {
54
+ messages: ChatMessage[];
55
+ isGenerating: boolean;
56
+ onRegenerate: () => Promise<void>;
57
+ }) => {
58
+ regenerateResponse = props.onRegenerate;
59
+ messageListMessages = props.messages;
60
+ messageListIsGenerating = props.isGenerating;
61
+ return null;
62
+ },
63
+ }));
64
+
65
+ const { generateChatResponse } = await import("@/modules/textGeneration");
66
+ const { generateFollowUpQuestion } = await import(
67
+ "@/modules/followUpQuestions"
68
+ );
69
+
70
+ /** A promise plus the handle to settle it, so a test can hold a call open. */
71
+ function deferred<T>() {
72
+ let resolve: (value: T) => void = () => undefined;
73
+ let reject: (reason: Error) => void = () => undefined;
74
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
75
+ resolve = resolvePromise;
76
+ reject = rejectPromise;
77
+ });
78
+ return { promise, resolve, reject };
79
+ }
80
+
81
+ const initialQuery = "What is a wake lock?";
82
+ const initialResponse = "An API that keeps the screen on.";
83
+
84
+ function renderChatInterface({ suppressInitialFollowUp = true } = {}) {
85
+ return render(
86
+ <MantineProvider>
87
+ <ChatInterface
88
+ initialQuery={initialQuery}
89
+ initialResponse={initialResponse}
90
+ suppressInitialFollowUp={suppressInitialFollowUp}
91
+ />
92
+ </MantineProvider>,
93
+ );
94
+ }
95
+
96
+ function stubWakeLockApi() {
97
+ const release = vi.fn(() => Promise.resolve());
98
+ const request = vi.fn(() =>
99
+ Promise.resolve({ released: false, release, addEventListener: vi.fn() }),
100
+ );
101
+
102
+ Object.defineProperty(navigator, "wakeLock", {
103
+ value: { request },
104
+ configurable: true,
105
+ });
106
+
107
+ return { request, release };
108
+ }
109
+
110
+ beforeEach(() => {
111
+ updateChatGenerationState({
112
+ isGeneratingResponse: false,
113
+ isGeneratingFollowUpQuestion: false,
114
+ });
115
+ updateChatInput("");
116
+ updateFollowUpQuestion("");
117
+ updateSuppressNextFollowUp(false);
118
+ messageListMessages = [];
119
+ messageListIsGenerating = false;
120
+ vi.mocked(generateChatResponse).mockResolvedValue("An answer.");
121
+ vi.mocked(generateFollowUpQuestion).mockResolvedValue("A question?");
122
+ });
123
+
124
+ afterEach(() => {
125
+ Reflect.deleteProperty(navigator, "wakeLock");
126
+ vi.resetAllMocks();
127
+ });
128
+
129
+ describe("ChatInterface generation state", () => {
130
+ it("leaves the follow-up question flag off after sending during its generation", async () => {
131
+ const earlierQuestion = deferred<string>();
132
+ vi.mocked(generateFollowUpQuestion).mockReturnValueOnce(
133
+ earlierQuestion.promise,
134
+ );
135
+
136
+ renderChatInterface({ suppressInitialFollowUp: false });
137
+
138
+ await act(async () => {});
139
+
140
+ expect(getChatGenerationState().isGeneratingFollowUpQuestion).toBe(true);
141
+
142
+ await act(async () => {
143
+ await sendMessage("And how do I use it?");
144
+ });
145
+
146
+ expect(getChatGenerationState().isGeneratingFollowUpQuestion).toBe(false);
147
+
148
+ await act(async () => {
149
+ earlierQuestion.resolve("An earlier question?");
150
+ });
151
+ });
152
+
153
+ it("keeps the response flag on when an earlier follow-up question completes mid-response", async () => {
154
+ const followUpQuestion = deferred<string>();
155
+ vi.mocked(generateFollowUpQuestion).mockReturnValueOnce(
156
+ followUpQuestion.promise,
157
+ );
158
+ const chatResponse = deferred<string>();
159
+ vi.mocked(generateChatResponse).mockReturnValueOnce(chatResponse.promise);
160
+
161
+ renderChatInterface({ suppressInitialFollowUp: false });
162
+
163
+ await act(async () => {});
164
+
165
+ expect(getChatGenerationState().isGeneratingFollowUpQuestion).toBe(true);
166
+
167
+ let send: Promise<void> = Promise.resolve();
168
+
169
+ await act(async () => {
170
+ send = sendMessage("And how do I use it?");
171
+ });
172
+
173
+ expect(getChatGenerationState().isGeneratingResponse).toBe(true);
174
+
175
+ await act(async () => {
176
+ followUpQuestion.resolve("An earlier question?");
177
+ });
178
+
179
+ expect(getChatGenerationState().isGeneratingResponse).toBe(true);
180
+
181
+ await act(async () => {
182
+ chatResponse.resolve("An answer.");
183
+ await send;
184
+ });
185
+ });
186
+
187
+ it("leaves a concurrent follow-up question flag alone when the response fails", async () => {
188
+ const followUpQuestion = deferred<string>();
189
+ vi.mocked(generateFollowUpQuestion).mockReturnValueOnce(
190
+ followUpQuestion.promise,
191
+ );
192
+ vi.mocked(generateChatResponse).mockRejectedValueOnce(
193
+ new Error("no model"),
194
+ );
195
+
196
+ renderChatInterface({ suppressInitialFollowUp: false });
197
+
198
+ await act(async () => {});
199
+
200
+ await act(async () => {
201
+ await sendMessage("And how do I use it?");
202
+ });
203
+
204
+ expect(getChatGenerationState()).toEqual({
205
+ isGeneratingResponse: false,
206
+ isGeneratingFollowUpQuestion: true,
207
+ });
208
+
209
+ await act(async () => {
210
+ followUpQuestion.resolve("An earlier question?");
211
+ });
212
+ });
213
+
214
+ it("starts one generation when two sends land in the same task", async () => {
215
+ renderChatInterface();
216
+
217
+ await act(async () => {
218
+ await Promise.all([
219
+ sendMessage("And how do I use it?"),
220
+ sendMessage("Does it work offline?"),
221
+ ]);
222
+ });
223
+
224
+ expect(generateChatResponse).toHaveBeenCalledTimes(1);
225
+ });
226
+
227
+ it("accepts a send while the follow-up question is still being generated", async () => {
228
+ const followUpQuestion = deferred<string>();
229
+ vi.mocked(generateFollowUpQuestion).mockReturnValueOnce(
230
+ followUpQuestion.promise,
231
+ );
232
+ // Streams the answer, so the placeholder row holds it the way it does in
233
+ // the app and a leftover copy would be visible.
234
+ vi.mocked(generateChatResponse).mockImplementationOnce(
235
+ async (_messages, onUpdate) => {
236
+ onUpdate("An answer.");
237
+ return "An answer.";
238
+ },
239
+ );
240
+
241
+ renderChatInterface();
242
+
243
+ let send: Promise<void> = Promise.resolve();
244
+
245
+ await act(async () => {
246
+ send = sendMessage("And how do I use it?");
247
+ });
248
+
249
+ expect(getChatGenerationState()).toEqual({
250
+ isGeneratingResponse: false,
251
+ isGeneratingFollowUpQuestion: true,
252
+ });
253
+
254
+ // The input stays enabled through this window, so the guard has to accept
255
+ // the send rather than refuse it silently, and the streamed placeholder
256
+ // must not leave a second copy of the answer in the list.
257
+ expect(messageListIsGenerating).toBe(false);
258
+ expect(
259
+ messageListMessages.filter((message) => message.content === "An answer."),
260
+ ).toHaveLength(1);
261
+
262
+ await act(async () => {
263
+ await sendMessage("Does it work offline?");
264
+ });
265
+
266
+ expect(generateChatResponse).toHaveBeenCalledTimes(2);
267
+
268
+ await act(async () => {
269
+ followUpQuestion.resolve("An earlier question?");
270
+ await send;
271
+ });
272
+ });
273
+
274
+ it("regenerates without clobbering a follow-up question in flight", async () => {
275
+ const followUpQuestion = deferred<string>();
276
+ vi.mocked(generateFollowUpQuestion).mockReturnValueOnce(
277
+ followUpQuestion.promise,
278
+ );
279
+ const regeneratedQuestion = deferred<string>();
280
+ vi.mocked(generateFollowUpQuestion).mockReturnValueOnce(
281
+ regeneratedQuestion.promise,
282
+ );
283
+ const chatResponse = deferred<string>();
284
+ vi.mocked(generateChatResponse)
285
+ .mockResolvedValueOnce("An answer.")
286
+ .mockReturnValueOnce(chatResponse.promise);
287
+
288
+ renderChatInterface();
289
+
290
+ let send: Promise<void> = Promise.resolve();
291
+
292
+ await act(async () => {
293
+ send = sendMessage("And how do I use it?");
294
+ });
295
+
296
+ let regenerate: Promise<void> = Promise.resolve();
297
+
298
+ await act(async () => {
299
+ regenerate = regenerateResponse();
300
+ });
301
+
302
+ expect(getChatGenerationState().isGeneratingResponse).toBe(true);
303
+ expect(getChatGenerationState().isGeneratingFollowUpQuestion).toBe(true);
304
+
305
+ await act(async () => {
306
+ followUpQuestion.resolve("An earlier question?");
307
+ });
308
+
309
+ expect(getChatGenerationState().isGeneratingResponse).toBe(true);
310
+
311
+ await act(async () => {
312
+ chatResponse.resolve("A regenerated answer.");
313
+ });
314
+
315
+ // The regenerate hands the lock over the way the send does: it must not
316
+ // stay in the response phase while its own question is still coming.
317
+ expect(getChatGenerationState()).toEqual({
318
+ isGeneratingResponse: false,
319
+ isGeneratingFollowUpQuestion: true,
320
+ });
321
+
322
+ await act(async () => {
323
+ regeneratedQuestion.resolve("A later question?");
324
+ await Promise.all([send, regenerate]);
325
+ });
326
+ });
327
+
328
+ it("keeps the follow-up question flag on until the newest call finishes", async () => {
329
+ const earlierQuestion = deferred<string>();
330
+ const newerQuestion = deferred<string>();
331
+ vi.mocked(generateFollowUpQuestion)
332
+ .mockReturnValueOnce(earlierQuestion.promise)
333
+ .mockReturnValueOnce(newerQuestion.promise);
334
+
335
+ renderChatInterface({ suppressInitialFollowUp: false });
336
+ await act(async () => {});
337
+
338
+ expect(getChatGenerationState().isGeneratingFollowUpQuestion).toBe(true);
339
+
340
+ let send: Promise<void> = Promise.resolve();
341
+ await act(async () => {
342
+ send = sendMessage("And how do I use it?");
343
+ });
344
+
345
+ // The orphaned call settles first. Its completion must write nothing.
346
+ await act(async () => {
347
+ earlierQuestion.resolve("The previous answer's question?");
348
+ });
349
+
350
+ expect(getChatGenerationState().isGeneratingFollowUpQuestion).toBe(true);
351
+ expect(getFollowUpQuestion()).toBe("");
352
+
353
+ await act(async () => {
354
+ newerQuestion.resolve("The newest question?");
355
+ await send;
356
+ });
357
+
358
+ expect(getFollowUpQuestion()).toBe("The newest question?");
359
+ expect(getChatGenerationState().isGeneratingFollowUpQuestion).toBe(false);
360
+ });
361
+
362
+ it("leaves both generation flags off after unmounting mid-generation", async () => {
363
+ const heldQuestion = deferred<string>();
364
+ vi.mocked(generateFollowUpQuestion).mockReturnValueOnce(
365
+ heldQuestion.promise,
366
+ );
367
+
368
+ const { unmount } = renderChatInterface({ suppressInitialFollowUp: false });
369
+ await act(async () => {});
370
+
371
+ expect(getChatGenerationState().isGeneratingFollowUpQuestion).toBe(true);
372
+
373
+ await act(async () => {
374
+ unmount();
375
+ });
376
+
377
+ expect(getChatGenerationState()).toEqual({
378
+ isGeneratingResponse: false,
379
+ isGeneratingFollowUpQuestion: false,
380
+ });
381
+
382
+ // The orphaned call lands after the unmount; it must not write into what
383
+ // is now the next mount's state.
384
+ await act(async () => {
385
+ heldQuestion.resolve("A question nobody asked for?");
386
+ });
387
+
388
+ expect(getChatGenerationState()).toEqual({
389
+ isGeneratingResponse: false,
390
+ isGeneratingFollowUpQuestion: false,
391
+ });
392
+ expect(getFollowUpQuestion()).toBe("");
393
+ });
394
+
395
+ it("leaves an in-flight call valid when a later one returns early", async () => {
396
+ const heldQuestion = deferred<string>();
397
+ vi.mocked(generateFollowUpQuestion).mockReturnValueOnce(
398
+ heldQuestion.promise,
399
+ );
400
+ // An empty answer makes the send's own follow-up call return at the
401
+ // `!currentResponse` guard, without ever reaching the request.
402
+ vi.mocked(generateChatResponse).mockResolvedValueOnce("");
403
+
404
+ renderChatInterface({ suppressInitialFollowUp: false });
405
+ await act(async () => {});
406
+
407
+ expect(getChatGenerationState().isGeneratingFollowUpQuestion).toBe(true);
408
+
409
+ await act(async () => {
410
+ await sendMessage("And how do I use it?");
411
+ });
412
+
413
+ // Only the held call ever asked for a question.
414
+ expect(generateFollowUpQuestion).toHaveBeenCalledTimes(1);
415
+
416
+ await act(async () => {
417
+ heldQuestion.resolve("The original question?");
418
+ });
419
+
420
+ // The early return bumped nothing, so the in-flight call is still the
421
+ // current one and clears the flag. Bumping before the guards would have
422
+ // orphaned it and left the flag, and the wake lock, held until unmount.
423
+ expect(getChatGenerationState().isGeneratingFollowUpQuestion).toBe(false);
424
+ });
425
+
426
+ it("blanks nothing when an orphaned call rejects after a newer one started", async () => {
427
+ const earlierQuestion = deferred<string>();
428
+ const newerQuestion = deferred<string>();
429
+ vi.mocked(generateFollowUpQuestion)
430
+ .mockReturnValueOnce(earlierQuestion.promise)
431
+ .mockReturnValueOnce(newerQuestion.promise);
432
+
433
+ renderChatInterface({ suppressInitialFollowUp: false });
434
+ await act(async () => {});
435
+
436
+ let send: Promise<void> = Promise.resolve();
437
+ await act(async () => {
438
+ send = sendMessage("And how do I use it?");
439
+ });
440
+
441
+ // The orphaned call fails. Its catch must not blank the newer one's work.
442
+ await act(async () => {
443
+ earlierQuestion.reject(new Error("no model"));
444
+ });
445
+
446
+ expect(getChatGenerationState().isGeneratingFollowUpQuestion).toBe(true);
447
+
448
+ await act(async () => {
449
+ newerQuestion.resolve("The newest question?");
450
+ await send;
451
+ });
452
+
453
+ expect(getFollowUpQuestion()).toBe("The newest question?");
454
+ expect(getChatGenerationState().isGeneratingFollowUpQuestion).toBe(false);
455
+ });
456
+
457
+ it("starts no follow-up question for a send that settles after unmount", async () => {
458
+ const chatResponse = deferred<string>();
459
+ vi.mocked(generateChatResponse).mockReturnValueOnce(chatResponse.promise);
460
+
461
+ const { unmount } = renderChatInterface();
462
+
463
+ let send: Promise<void> = Promise.resolve();
464
+ await act(async () => {
465
+ send = sendMessage("And how do I use it?");
466
+ });
467
+
468
+ await act(async () => {
469
+ unmount();
470
+ });
471
+
472
+ // The send closure runs to the end regardless, and asks for a question the
473
+ // dead instance's token would have accepted.
474
+ await act(async () => {
475
+ chatResponse.resolve("An answer.");
476
+ await send;
477
+ });
478
+
479
+ expect(generateFollowUpQuestion).not.toHaveBeenCalled();
480
+ expect(getFollowUpQuestion()).toBe("");
481
+ expect(getChatGenerationState().isGeneratingFollowUpQuestion).toBe(false);
482
+ });
483
+
484
+ it("leaves the next mount's response flag alone when an orphaned send settles", async () => {
485
+ const firstResponse = deferred<string>();
486
+ const secondResponse = deferred<string>();
487
+ vi.mocked(generateChatResponse)
488
+ .mockReturnValueOnce(firstResponse.promise)
489
+ .mockReturnValueOnce(secondResponse.promise);
490
+
491
+ const first = renderChatInterface();
492
+
493
+ let firstSend: Promise<void> = Promise.resolve();
494
+ await act(async () => {
495
+ firstSend = sendMessage("And how do I use it?");
496
+ });
497
+
498
+ await act(async () => {
499
+ first.unmount();
500
+ });
501
+
502
+ renderChatInterface();
503
+
504
+ let secondSend: Promise<void> = Promise.resolve();
505
+ await act(async () => {
506
+ secondSend = sendMessage("Does it work offline?");
507
+ });
508
+
509
+ expect(getChatGenerationState().isGeneratingResponse).toBe(true);
510
+ // The live generation really started, rather than the flag being stale.
511
+ expect(generateChatResponse).toHaveBeenCalledTimes(2);
512
+
513
+ // The orphan's `finally` must not clear the live generation's flag, which
514
+ // would drop the spinner and let a third send start alongside it.
515
+ await act(async () => {
516
+ firstResponse.resolve("The orphaned answer.");
517
+ await firstSend;
518
+ });
519
+
520
+ expect(getChatGenerationState().isGeneratingResponse).toBe(true);
521
+
522
+ await act(async () => {
523
+ secondResponse.resolve("The live answer.");
524
+ await secondSend;
525
+ });
526
+
527
+ expect(getChatGenerationState().isGeneratingResponse).toBe(false);
528
+ });
529
+
530
+ it("leaves the next mount's response flag alone when an orphaned regenerate settles", async () => {
531
+ const firstResponse = deferred<string>();
532
+ const secondResponse = deferred<string>();
533
+ vi.mocked(generateChatResponse)
534
+ .mockResolvedValueOnce("An answer.")
535
+ .mockReturnValueOnce(firstResponse.promise)
536
+ .mockReturnValueOnce(secondResponse.promise);
537
+
538
+ const first = renderChatInterface();
539
+
540
+ await act(async () => {
541
+ await sendMessage("And how do I use it?");
542
+ });
543
+
544
+ let firstRegenerate: Promise<void> = Promise.resolve();
545
+ await act(async () => {
546
+ firstRegenerate = regenerateResponse();
547
+ });
548
+
549
+ await act(async () => {
550
+ first.unmount();
551
+ });
552
+
553
+ renderChatInterface();
554
+
555
+ let secondSend: Promise<void> = Promise.resolve();
556
+ await act(async () => {
557
+ secondSend = sendMessage("Does it work offline?");
558
+ });
559
+
560
+ expect(getChatGenerationState().isGeneratingResponse).toBe(true);
561
+ // The live generation really started, rather than the flag being stale.
562
+ expect(generateChatResponse).toHaveBeenCalledTimes(3);
563
+
564
+ await act(async () => {
565
+ firstResponse.resolve("The orphaned regeneration.");
566
+ await firstRegenerate;
567
+ });
568
+
569
+ expect(getChatGenerationState().isGeneratingResponse).toBe(true);
570
+
571
+ await act(async () => {
572
+ secondResponse.resolve("The live answer.");
573
+ await secondSend;
574
+ });
575
+
576
+ expect(getChatGenerationState().isGeneratingResponse).toBe(false);
577
+ });
578
+
579
+ it("starts one re-generation when two regenerates land in the same task", async () => {
580
+ renderChatInterface();
581
+
582
+ await act(async () => {
583
+ await sendMessage("And how do I use it?");
584
+ });
585
+
586
+ await act(async () => {
587
+ await Promise.all([regenerateResponse(), regenerateResponse()]);
588
+ });
589
+
590
+ expect(generateChatResponse).toHaveBeenCalledTimes(2);
591
+ });
592
+
593
+ it("keeps the response flag on when a follow-up question fails mid-response", async () => {
594
+ const earlierQuestion = deferred<string>();
595
+ vi.mocked(generateFollowUpQuestion).mockReturnValueOnce(
596
+ earlierQuestion.promise,
597
+ );
598
+ const chatResponse = deferred<string>();
599
+ vi.mocked(generateChatResponse).mockReturnValueOnce(chatResponse.promise);
600
+
601
+ renderChatInterface({ suppressInitialFollowUp: false });
602
+
603
+ await act(async () => {});
604
+
605
+ let send: Promise<void> = Promise.resolve();
606
+
607
+ await act(async () => {
608
+ send = sendMessage("And how do I use it?");
609
+ });
610
+
611
+ await act(async () => {
612
+ earlierQuestion.reject(new Error("no model"));
613
+ });
614
+
615
+ expect(getChatGenerationState()).toEqual({
616
+ isGeneratingResponse: true,
617
+ isGeneratingFollowUpQuestion: false,
618
+ });
619
+
620
+ await act(async () => {
621
+ chatResponse.resolve("An answer.");
622
+ await send;
623
+ });
624
+ });
625
+
626
+ it("holds the screen wake lock from the response through the follow-up question", async () => {
627
+ const { request, release } = stubWakeLockApi();
628
+ const followUpQuestion = deferred<string>();
629
+ vi.mocked(generateFollowUpQuestion).mockReturnValueOnce(
630
+ followUpQuestion.promise,
631
+ );
632
+
633
+ renderChatInterface();
634
+
635
+ let send: Promise<void> = Promise.resolve();
636
+
637
+ await act(async () => {
638
+ send = sendMessage("And how do I use it?");
639
+ });
640
+
641
+ expect(request).toHaveBeenCalledExactlyOnceWith("screen");
642
+ expect(release).not.toHaveBeenCalled();
643
+
644
+ await act(async () => {
645
+ followUpQuestion.resolve("An earlier question?");
646
+ await send;
647
+ });
648
+
649
+ expect(release).toHaveBeenCalled();
650
+ });
651
+ });
client/components/AiResponse/ChatInterface.tsx ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Card, Stack } from "@mantine/core";
2
+ import { usePubSub } from "create-pubsub/react";
3
+ import {
4
+ type KeyboardEvent,
5
+ useCallback,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import throttle from "throttleit";
11
+ import { useScreenWakeLock } from "@/hooks/useScreenWakeLock";
12
+ import { persistChatMessages, runFollowUpSearch } from "@/modules/chatHelpers";
13
+ import { generateFollowUpQuestion } from "@/modules/followUpQuestions";
14
+ import { handleEnterKeyDown } from "@/modules/keyboard";
15
+ import { addLogEntry } from "@/modules/logEntries";
16
+ import { showAiCompleteNotification } from "@/modules/notifications";
17
+ import {
18
+ chatGenerationStatePubSub,
19
+ chatInputPubSub,
20
+ followUpQuestionPubSub,
21
+ getChatGenerationState,
22
+ imageSearchResultsPubSub,
23
+ queryPubSub,
24
+ settingsPubSub,
25
+ suppressNextFollowUpPubSub,
26
+ textSearchResultsPubSub,
27
+ } from "@/modules/pubSub";
28
+ import { generateChatResponse } from "@/modules/textGeneration";
29
+ import type { ChatMessage } from "@/modules/types";
30
+ import ChatHeader from "./ChatHeader";
31
+ import ChatInputArea from "./ChatInputArea";
32
+ import MessageList from "./MessageList";
33
+
34
+ interface ChatInterfaceProps {
35
+ initialQuery?: string;
36
+ initialResponse?: string;
37
+ initialMessages?: ChatMessage[];
38
+ suppressInitialFollowUp?: boolean;
39
+ }
40
+
41
+ export default function ChatInterface({
42
+ initialQuery,
43
+ initialResponse,
44
+ initialMessages,
45
+ suppressInitialFollowUp,
46
+ }: ChatInterfaceProps) {
47
+ const initialMessagesArray =
48
+ initialMessages &&
49
+ initialMessages.length > 0 &&
50
+ initialQuery &&
51
+ initialResponse
52
+ ? [
53
+ { role: "user" as const, content: initialQuery },
54
+ { role: "assistant" as const, content: initialResponse },
55
+ ...initialMessages,
56
+ ]
57
+ : initialMessages || [];
58
+
59
+ const [messages, setMessages] = useState<ChatMessage[]>(initialMessagesArray);
60
+ const [input, setInput] = usePubSub(chatInputPubSub);
61
+ const [generationState, setGenerationState] = usePubSub(
62
+ chatGenerationStatePubSub,
63
+ );
64
+ const [, setFollowUpQuestion] = usePubSub(followUpQuestionPubSub);
65
+ const [textSearchResults] = usePubSub(textSearchResultsPubSub);
66
+ const [imageSearchResults] = usePubSub(imageSearchResultsPubSub);
67
+ const [currentQuery] = usePubSub(queryPubSub);
68
+ const [suppressNextFollowUp] = usePubSub(suppressNextFollowUpPubSub);
69
+ const [previousFollowUpQuestions, setPreviousFollowUpQuestions] = useState<
70
+ string[]
71
+ >([]);
72
+ const [settings] = usePubSub(settingsPubSub);
73
+ const [streamedResponse, setStreamedResponse] = useState("");
74
+ const hasInitialized = useRef(false);
75
+ const prevInitialMessagesRef = useRef<ChatMessage[] | undefined>(undefined);
76
+ /**
77
+ * Identifies the newest `regenerateFollowUpQuestion` call. A boolean cannot
78
+ * represent two in flight, so a completion whose id is no longer current does
79
+ * nothing: otherwise whichever finished first would clear the flag while the
80
+ * other was still working, dropping the wake lock, and an orphaned call would
81
+ * write the previous answer's question into the input placeholder.
82
+ */
83
+ const followUpInvocationRef = useRef(0);
84
+ /**
85
+ * False once this instance has unmounted. The pubsub setters are module-level
86
+ * publishers, so an orphaned flow keeps writing into whatever is mounted now
87
+ * unless it checks first.
88
+ */
89
+ const isMountedRef = useRef(true);
90
+ const updateStreamedResponse = useCallback(
91
+ throttle((response: string) => {
92
+ setStreamedResponse(response);
93
+ }, 1000 / 12),
94
+ [],
95
+ );
96
+
97
+ useScreenWakeLock(
98
+ generationState.isGeneratingResponse ||
99
+ generationState.isGeneratingFollowUpQuestion,
100
+ );
101
+
102
+ const regenerateFollowUpQuestion = useCallback(
103
+ async (currentQuery: string, currentResponse: string) => {
104
+ // A send that settles after unmount still runs its closure to the end and
105
+ // calls this. Bumping the dead instance's token would not stop it: it
106
+ // would pass its own check and write into the live mount.
107
+ if (!isMountedRef.current) return;
108
+ if (suppressNextFollowUp) return;
109
+ if (!currentResponse || !currentQuery.trim()) return;
110
+
111
+ // After the guards, never before them. A call that returns early never
112
+ // set the flag, so invalidating the in-flight one would make its
113
+ // completion a no-op and leave the flag, and the wake lock, held until
114
+ // unmount.
115
+ const invocation = ++followUpInvocationRef.current;
116
+
117
+ try {
118
+ setGenerationState({
119
+ ...getChatGenerationState(),
120
+ isGeneratingFollowUpQuestion: true,
121
+ });
122
+
123
+ const newQuestion = await generateFollowUpQuestion({
124
+ topic: currentQuery,
125
+ currentContent: currentResponse,
126
+ previousQuestions: previousFollowUpQuestions,
127
+ });
128
+
129
+ // A newer call, or an unmount, took over while this one awaited.
130
+ if (invocation !== followUpInvocationRef.current) return;
131
+
132
+ setPreviousFollowUpQuestions((prev) =>
133
+ [...prev, newQuestion].slice(-5),
134
+ );
135
+ setFollowUpQuestion(newQuestion);
136
+ setGenerationState({
137
+ ...getChatGenerationState(),
138
+ isGeneratingFollowUpQuestion: false,
139
+ });
140
+ } catch (_) {
141
+ if (invocation !== followUpInvocationRef.current) return;
142
+ setFollowUpQuestion("");
143
+ setGenerationState({
144
+ ...getChatGenerationState(),
145
+ isGeneratingFollowUpQuestion: false,
146
+ });
147
+ }
148
+ },
149
+ [
150
+ setFollowUpQuestion,
151
+ setGenerationState,
152
+ previousFollowUpQuestions,
153
+ suppressNextFollowUp,
154
+ ],
155
+ );
156
+
157
+ useEffect(() => {
158
+ const messagesChanged =
159
+ !prevInitialMessagesRef.current ||
160
+ JSON.stringify(prevInitialMessagesRef.current) !==
161
+ JSON.stringify(initialMessages);
162
+
163
+ if (!messagesChanged) return;
164
+
165
+ prevInitialMessagesRef.current = initialMessages;
166
+
167
+ const newInitialMessagesArray =
168
+ initialMessages &&
169
+ initialMessages.length > 0 &&
170
+ initialQuery &&
171
+ initialResponse
172
+ ? [
173
+ { role: "user" as const, content: initialQuery },
174
+ { role: "assistant" as const, content: initialResponse },
175
+ ...initialMessages,
176
+ ]
177
+ : initialMessages || [];
178
+
179
+ if (newInitialMessagesArray.length > 0) {
180
+ setMessages(newInitialMessagesArray);
181
+ } else if (initialQuery && initialResponse) {
182
+ setMessages([
183
+ { role: "user", content: initialQuery },
184
+ { role: "assistant", content: initialResponse },
185
+ ]);
186
+ }
187
+ }, [initialQuery, initialResponse, initialMessages]);
188
+
189
+ useEffect(() => {
190
+ if (suppressNextFollowUp) {
191
+ hasInitialized.current = true;
192
+ return;
193
+ }
194
+ if (suppressInitialFollowUp) return;
195
+ if (hasInitialized.current) return;
196
+
197
+ if (initialMessages && initialMessages.length > 0) {
198
+ const lastAssistant = messages
199
+ .filter((m) => m.role === "assistant")
200
+ .pop();
201
+ const lastUser = messages.filter((m) => m.role === "user").pop();
202
+ if (lastUser && lastAssistant) {
203
+ regenerateFollowUpQuestion(lastUser.content, lastAssistant.content);
204
+ hasInitialized.current = true;
205
+ }
206
+ } else if (messages.length >= 2 && initialQuery && initialResponse) {
207
+ regenerateFollowUpQuestion(initialQuery, initialResponse);
208
+ hasInitialized.current = true;
209
+ }
210
+ }, [
211
+ initialQuery,
212
+ initialResponse,
213
+ initialMessages,
214
+ messages,
215
+ regenerateFollowUpQuestion,
216
+ suppressInitialFollowUp,
217
+ suppressNextFollowUp,
218
+ ]);
219
+
220
+ useEffect(() => {
221
+ // Re-armed rather than left to the initial value, so a future re-run of
222
+ // this effect cannot leave the instance marked dead. It does not make the
223
+ // component StrictMode-safe: there the cleanup's token bump would orphan
224
+ // the mount effect's call while `hasInitialized` stays true, and the first
225
+ // answer would get no follow-up question at all.
226
+ isMountedRef.current = true;
227
+
228
+ return () => {
229
+ // Invalidate any in-flight call so its completion cannot write into the
230
+ // next mount, then clear the generation flags the same way the question
231
+ // is cleared: an orphaned flow's `finally` would otherwise resurrect them
232
+ // there, and nothing else resets this channel on unmount.
233
+ isMountedRef.current = false;
234
+ followUpInvocationRef.current += 1;
235
+ setFollowUpQuestion("");
236
+ setPreviousFollowUpQuestions([]);
237
+ setGenerationState({
238
+ isGeneratingResponse: false,
239
+ isGeneratingFollowUpQuestion: false,
240
+ });
241
+ };
242
+ }, [setFollowUpQuestion, setGenerationState]);
243
+
244
+ const handleEditMessage = useCallback(
245
+ (absoluteIndex: number) => {
246
+ const target = messages[absoluteIndex];
247
+ if (target?.role !== "user") return;
248
+ setInput(target.content);
249
+ setMessages(messages.slice(0, absoluteIndex));
250
+ setFollowUpQuestion("");
251
+ },
252
+ [messages, setInput, setFollowUpQuestion],
253
+ );
254
+
255
+ const handleRegenerateResponse = useCallback(async () => {
256
+ if (
257
+ getChatGenerationState().isGeneratingResponse ||
258
+ messages.length < 3 ||
259
+ messages[messages.length - 1].role !== "assistant"
260
+ )
261
+ return;
262
+
263
+ const history = messages.slice(0, -1);
264
+ const lastUser = history[history.length - 1];
265
+
266
+ setMessages(history);
267
+ setGenerationState({
268
+ ...getChatGenerationState(),
269
+ isGeneratingResponse: true,
270
+ });
271
+ setFollowUpQuestion("");
272
+ setStreamedResponse("");
273
+
274
+ try {
275
+ const finalResponse = await generateChatResponse(
276
+ history,
277
+ updateStreamedResponse,
278
+ );
279
+
280
+ setMessages((prev) => [
281
+ ...prev,
282
+ { role: "assistant", content: finalResponse },
283
+ ]);
284
+
285
+ addLogEntry("AI response re-generated");
286
+
287
+ if (lastUser?.role === "user") {
288
+ if (settings.enableNotificationOnAiComplete) {
289
+ showAiCompleteNotification(lastUser.content);
290
+ }
291
+ // Not awaited: the follow-up question raises its own flag
292
+ // synchronously, so the `finally` below can hand the wake lock over
293
+ // and release the response flag while that question is still coming.
294
+ regenerateFollowUpQuestion(lastUser.content, finalResponse);
295
+ }
296
+ } catch (error) {
297
+ addLogEntry(`Error re-generating response: ${error}`);
298
+ } finally {
299
+ if (isMountedRef.current) {
300
+ setGenerationState({
301
+ ...getChatGenerationState(),
302
+ isGeneratingResponse: false,
303
+ });
304
+ }
305
+ }
306
+ }, [
307
+ messages,
308
+ regenerateFollowUpQuestion,
309
+ settings,
310
+ setFollowUpQuestion,
311
+ setGenerationState,
312
+ updateStreamedResponse,
313
+ ]);
314
+
315
+ const handleSend = useCallback(
316
+ async (textToSend?: string) => {
317
+ const currentInput = textToSend ?? input;
318
+ if (
319
+ currentInput.trim() === "" ||
320
+ getChatGenerationState().isGeneratingResponse
321
+ ) {
322
+ return;
323
+ }
324
+
325
+ const userMessage: ChatMessage = { role: "user", content: currentInput };
326
+ const newMessages: ChatMessage[] = [...messages, userMessage];
327
+
328
+ setMessages(newMessages);
329
+ if (!textToSend) setInput("");
330
+ setGenerationState({
331
+ ...getChatGenerationState(),
332
+ isGeneratingResponse: true,
333
+ });
334
+ setFollowUpQuestion("");
335
+ setStreamedResponse("");
336
+
337
+ try {
338
+ await runFollowUpSearch(
339
+ newMessages,
340
+ currentInput,
341
+ settings,
342
+ textSearchResults,
343
+ imageSearchResults,
344
+ );
345
+ } catch (error) {
346
+ addLogEntry(`Error in follow-up search: ${error}`);
347
+ }
348
+
349
+ try {
350
+ const finalResponse = await generateChatResponse(
351
+ newMessages,
352
+ updateStreamedResponse,
353
+ );
354
+
355
+ setMessages((prevMessages) => [
356
+ ...prevMessages,
357
+ { role: "assistant", content: finalResponse },
358
+ ]);
359
+
360
+ addLogEntry("AI response completed");
361
+
362
+ if (settings.enableNotificationOnAiComplete) {
363
+ showAiCompleteNotification(currentInput);
364
+ }
365
+
366
+ await persistChatMessages(currentQuery, currentInput, finalResponse);
367
+
368
+ regenerateFollowUpQuestion(currentInput, finalResponse);
369
+ } catch (error) {
370
+ addLogEntry(`Error in chat response: ${error}`);
371
+ setMessages((prevMessages) => [
372
+ ...prevMessages,
373
+ {
374
+ role: "assistant",
375
+ content:
376
+ "Sorry, I encountered an error while generating a response.",
377
+ },
378
+ ]);
379
+ } finally {
380
+ if (isMountedRef.current) {
381
+ setGenerationState({
382
+ ...getChatGenerationState(),
383
+ isGeneratingResponse: false,
384
+ });
385
+ }
386
+ }
387
+ },
388
+ [
389
+ messages,
390
+ settings,
391
+ input,
392
+ regenerateFollowUpQuestion,
393
+ setFollowUpQuestion,
394
+ setGenerationState,
395
+ setInput,
396
+ updateStreamedResponse,
397
+ currentQuery,
398
+ textSearchResults,
399
+ imageSearchResults,
400
+ ],
401
+ );
402
+
403
+ const handleKeyDown = useCallback(
404
+ (event: KeyboardEvent<HTMLTextAreaElement>) => {
405
+ handleEnterKeyDown(event, settings, handleSend);
406
+ },
407
+ [settings, handleSend],
408
+ );
409
+
410
+ return (
411
+ <Card withBorder shadow="sm" radius="md">
412
+ <Card.Section withBorder inheritPadding py="xs">
413
+ <ChatHeader messages={messages} />
414
+ </Card.Section>
415
+ <Stack gap="md" pt="md">
416
+ <MessageList
417
+ messages={
418
+ generationState.isGeneratingResponse
419
+ ? [...messages, { role: "assistant", content: streamedResponse }]
420
+ : messages
421
+ }
422
+ onEditMessage={handleEditMessage}
423
+ onRegenerate={handleRegenerateResponse}
424
+ isGenerating={generationState.isGeneratingResponse}
425
+ />
426
+ <ChatInputArea onKeyDown={handleKeyDown} handleSend={handleSend} />
427
+ </Stack>
428
+ </Card>
429
+ );
430
+ }
client/components/AiResponse/CopyIconButton.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
2
+ import { IconCheck, IconCopy } from "@tabler/icons-react";
3
+
4
+ interface CopyIconButtonProps {
5
+ value: string;
6
+ tooltipLabel?: string;
7
+ }
8
+
9
+ export default function CopyIconButton({
10
+ value,
11
+ tooltipLabel = "Copy",
12
+ }: CopyIconButtonProps) {
13
+ return (
14
+ <CopyButton value={value} timeout={2000}>
15
+ {({ copied, copy }) => (
16
+ <Tooltip
17
+ label={copied ? "Copied" : tooltipLabel}
18
+ withArrow
19
+ position="right"
20
+ >
21
+ <ActionIcon
22
+ color={copied ? "teal" : "gray"}
23
+ variant="subtle"
24
+ onClick={copy}
25
+ >
26
+ {copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
27
+ </ActionIcon>
28
+ </Tooltip>
29
+ )}
30
+ </CopyButton>
31
+ );
32
+ }
client/components/AiResponse/EnableAiResponsePrompt.test.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MantineProvider } from "@mantine/core";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { describe, expect, it } from "vitest";
4
+ import EnableAiResponsePrompt from "./EnableAiResponsePrompt";
5
+
6
+ describe("EnableAiResponsePrompt", () => {
7
+ it("is announced politely (status), not assertively (alert)", () => {
8
+ render(
9
+ <MantineProvider>
10
+ <EnableAiResponsePrompt onAccept={() => {}} onDecline={() => {}} />
11
+ </MantineProvider>,
12
+ );
13
+
14
+ expect(screen.getByRole("status")).toBeInTheDocument();
15
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
16
+ });
17
+
18
+ it("offers accept and decline", () => {
19
+ render(
20
+ <MantineProvider>
21
+ <EnableAiResponsePrompt onAccept={() => {}} onDecline={() => {}} />
22
+ </MantineProvider>,
23
+ );
24
+
25
+ expect(
26
+ screen.getByRole("button", { name: "Yes, please" }),
27
+ ).toBeInTheDocument();
28
+ expect(
29
+ screen.getByRole("button", { name: "No, thanks" }),
30
+ ).toBeInTheDocument();
31
+ });
32
+ });
client/components/AiResponse/EnableAiResponsePrompt.tsx ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ ActionIcon,
3
+ Alert,
4
+ Button,
5
+ Grid,
6
+ Group,
7
+ Popover,
8
+ Stack,
9
+ Text,
10
+ } from "@mantine/core";
11
+ import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
12
+
13
+ interface EnableAiResponsePromptProps {
14
+ onAccept: () => void;
15
+ onDecline: () => void;
16
+ }
17
+
18
+ export default function EnableAiResponsePrompt({
19
+ onAccept,
20
+ onDecline,
21
+ }: EnableAiResponsePromptProps) {
22
+ const helpContent = (
23
+ <Stack gap="xs" p="xs">
24
+ <Text size="sm">
25
+ MiniSearch is a web-searching app with an integrated AI assistant.
26
+ </Text>
27
+ <Text size="sm">
28
+ With AI Responses enabled, it will generate summaries and answer
29
+ questions based on search results.
30
+ </Text>
31
+ <Text size="sm">
32
+ If disabled, it will function as a classic web search tool.
33
+ </Text>
34
+ <Text size="sm" c="dimmed" component="em">
35
+ You can toggle this feature at anytime through the Menu.
36
+ </Text>
37
+ </Stack>
38
+ );
39
+
40
+ return (
41
+ <Alert role="status" variant="light" color="blue" p="xs">
42
+ <Grid justify="space-between" align="center">
43
+ <Grid.Col span="content">
44
+ <Group gap="xs">
45
+ <Text fw={500}>Enable AI Responses?</Text>
46
+ <Popover
47
+ width={300}
48
+ styles={{ dropdown: { maxWidth: "92vw" } }}
49
+ position="bottom"
50
+ withArrow
51
+ shadow="md"
52
+ >
53
+ <Popover.Target>
54
+ <ActionIcon variant="subtle" color="blue" size="sm">
55
+ <IconInfoCircle size="1rem" />
56
+ </ActionIcon>
57
+ </Popover.Target>
58
+ <Popover.Dropdown>{helpContent}</Popover.Dropdown>
59
+ </Popover>
60
+ </Group>
61
+ </Grid.Col>
62
+ <Grid.Col span="auto">
63
+ <Group justify="end">
64
+ <Button
65
+ variant="subtle"
66
+ color="gray"
67
+ leftSection={<IconX size="1rem" />}
68
+ onClick={onDecline}
69
+ size="xs"
70
+ >
71
+ No, thanks
72
+ </Button>
73
+ <Button
74
+ leftSection={<IconCheck size="1rem" />}
75
+ onClick={onAccept}
76
+ size="xs"
77
+ >
78
+ Yes, please
79
+ </Button>
80
+ </Group>
81
+ </Grid.Col>
82
+ </Grid>
83
+ </Alert>
84
+ );
85
+ }
client/components/AiResponse/ExpandableLink.tsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MantineTheme } from "@mantine/core";
2
+ import { Button } from "@mantine/core";
3
+ import React from "react";
4
+
5
+ interface ExpandableLinkProps {
6
+ href: string;
7
+ children: React.ReactNode;
8
+ }
9
+
10
+ export default function ExpandableLink({
11
+ href,
12
+ children,
13
+ }: ExpandableLinkProps) {
14
+ const childContent = children?.toString() || "";
15
+ const firstChar = childContent.charAt(0);
16
+ const [isExpanded, setIsExpanded] = React.useState(true);
17
+ const timerRef = React.useRef<number | null>(null);
18
+
19
+ React.useEffect(() => {
20
+ timerRef.current = window.setTimeout(() => {
21
+ setIsExpanded(false);
22
+ timerRef.current = null;
23
+ }, 3000);
24
+
25
+ return () => {
26
+ if (timerRef.current) {
27
+ clearTimeout(timerRef.current);
28
+ }
29
+ };
30
+ }, []);
31
+
32
+ const handleMouseEnter = () => {
33
+ if (timerRef.current) {
34
+ clearTimeout(timerRef.current);
35
+ timerRef.current = null;
36
+ }
37
+ setIsExpanded(true);
38
+ };
39
+
40
+ const handleMouseLeave = () => {
41
+ timerRef.current = window.setTimeout(() => {
42
+ setIsExpanded(false);
43
+ timerRef.current = null;
44
+ }, 3000);
45
+ };
46
+
47
+ const fullTextRef = React.useRef<HTMLDivElement>(null);
48
+ const [fullTextWidth, setFullTextWidth] = React.useState(0);
49
+
50
+ React.useEffect(() => {
51
+ const measureText = () => {
52
+ if (fullTextRef.current) {
53
+ setFullTextWidth(fullTextRef.current.scrollWidth);
54
+ }
55
+ };
56
+
57
+ measureText();
58
+
59
+ window.addEventListener("resize", measureText);
60
+ return () => {
61
+ window.removeEventListener("resize", measureText);
62
+ };
63
+ }, []);
64
+
65
+ return (
66
+ <Button
67
+ component="a"
68
+ href={href}
69
+ target="_blank"
70
+ rel="nofollow noopener noreferrer"
71
+ variant="light"
72
+ color="gray"
73
+ size="compact-xs"
74
+ radius="xl"
75
+ style={(theme: MantineTheme) => ({
76
+ textDecoration: "none",
77
+ transform: "translateY(-2px)",
78
+ overflow: "hidden",
79
+ position: "relative",
80
+ width: isExpanded ? `${fullTextWidth + theme.spacing.md}px` : "2em",
81
+ transition: "width 0.3s ease-in-out",
82
+ textAlign: "center",
83
+ })}
84
+ onMouseEnter={handleMouseEnter}
85
+ onMouseLeave={handleMouseLeave}
86
+ onFocus={handleMouseEnter}
87
+ onBlur={handleMouseLeave}
88
+ >
89
+ <span
90
+ style={{
91
+ position: "absolute",
92
+ top: 0,
93
+ left: 0,
94
+ right: 0,
95
+ bottom: 0,
96
+ display: "flex",
97
+ alignItems: "center",
98
+ justifyContent: "center",
99
+ opacity: isExpanded ? 0 : 1,
100
+ transition: "opacity 0.2s ease-in-out",
101
+ }}
102
+ >
103
+ {firstChar}
104
+ </span>
105
+ <span
106
+ ref={fullTextRef}
107
+ style={{
108
+ opacity: isExpanded ? 1 : 0,
109
+ transition: "opacity 0.3s ease-in-out",
110
+ visibility: isExpanded ? "visible" : "hidden",
111
+ whiteSpace: "nowrap",
112
+ display: "flex",
113
+ alignItems: "center",
114
+ justifyContent: "center",
115
+ height: "100%",
116
+ position: "relative",
117
+ }}
118
+ >
119
+ {children}
120
+ </span>
121
+ </Button>
122
+ );
123
+ }
client/components/AiResponse/FormattedMarkdown.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Typography } from "@mantine/core";
2
+ import { useReasoningContent } from "./hooks/useReasoningContent";
3
+ import MarkdownRenderer from "./MarkdownRenderer";
4
+ import ReasoningSection from "./ReasoningSection";
5
+
6
+ interface FormattedMarkdownProps {
7
+ children: string;
8
+ className?: string;
9
+ enableCopy?: boolean;
10
+ }
11
+
12
+ export default function FormattedMarkdown({
13
+ children,
14
+ className = "",
15
+ enableCopy = true,
16
+ }: FormattedMarkdownProps) {
17
+ const { reasoningContent, mainContent, isGenerating } =
18
+ useReasoningContent(children);
19
+
20
+ if (!children && !reasoningContent) {
21
+ return null;
22
+ }
23
+
24
+ return (
25
+ <Typography p="lg">
26
+ {reasoningContent && (
27
+ <ReasoningSection
28
+ content={reasoningContent}
29
+ isGenerating={isGenerating}
30
+ />
31
+ )}
32
+ {mainContent && (
33
+ <MarkdownRenderer
34
+ content={mainContent}
35
+ enableCopy={enableCopy}
36
+ className={className}
37
+ />
38
+ )}
39
+ </Typography>
40
+ );
41
+ }
client/components/AiResponse/LoadingModelContent.test.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MantineProvider } from "@mantine/core";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { describe, expect, it } from "vitest";
4
+ import LoadingModelContent from "./LoadingModelContent";
5
+
6
+ function renderContent(props: {
7
+ modelLoadingProgress: number;
8
+ modelSizeInMegabytes: number;
9
+ }) {
10
+ return render(
11
+ <MantineProvider>
12
+ <LoadingModelContent {...props} />
13
+ </MantineProvider>,
14
+ );
15
+ }
16
+
17
+ describe("LoadingModelContent", () => {
18
+ it("omits the size while it is still unknown, keeping the percentage", () => {
19
+ renderContent({ modelLoadingProgress: 70, modelSizeInMegabytes: 0 });
20
+
21
+ expect(screen.queryByText(/MB/)).not.toBeInTheDocument();
22
+ expect(screen.getByText("70.0%")).toBeInTheDocument();
23
+ });
24
+
25
+ it("shows how much of the model was downloaded once the size is known", () => {
26
+ renderContent({ modelLoadingProgress: 70, modelSizeInMegabytes: 250 });
27
+
28
+ expect(screen.getByText("175 MB / 250 MB")).toBeInTheDocument();
29
+ expect(screen.getByText("70.0%")).toBeInTheDocument();
30
+ });
31
+ });
client/components/AiResponse/LoadingModelContent.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Card, Group, Progress, Stack, Text } from "@mantine/core";
2
+
3
+ export default function LoadingModelContent({
4
+ modelLoadingProgress,
5
+ modelSizeInMegabytes,
6
+ }: {
7
+ modelLoadingProgress: number;
8
+ modelSizeInMegabytes: number;
9
+ }) {
10
+ const isLoadingStarting = modelLoadingProgress === 0;
11
+ const isLoadingComplete = modelLoadingProgress === 100;
12
+ const percent =
13
+ isLoadingComplete || isLoadingStarting ? 100 : modelLoadingProgress;
14
+ const strokeColor = percent === 100 ? "#52c41a" : "#3385ff";
15
+ const isModelSizeKnown = modelSizeInMegabytes > 0;
16
+ const downloadedSize = (modelSizeInMegabytes * modelLoadingProgress) / 100;
17
+ const sizeText = `${downloadedSize.toFixed(0)} MB / ${modelSizeInMegabytes.toFixed(0)} MB`;
18
+
19
+ return (
20
+ <Card withBorder shadow="sm" radius="md">
21
+ <Card.Section withBorder inheritPadding py="xs">
22
+ <Text fw={500}>Loading AI...</Text>
23
+ </Card.Section>
24
+ <Card.Section withBorder inheritPadding py="md">
25
+ <Stack gap="xs">
26
+ <Progress color={strokeColor} value={percent} animated />
27
+ {!isLoadingStarting && (
28
+ <Group>
29
+ {isModelSizeKnown && (
30
+ <Text size="sm" c="dimmed">
31
+ {sizeText}
32
+ </Text>
33
+ )}
34
+ <Text size="sm" c="dimmed" ml="auto">
35
+ {percent.toFixed(1)}%
36
+ </Text>
37
+ </Group>
38
+ )}
39
+ </Stack>
40
+ </Card.Section>
41
+ </Card>
42
+ );
43
+ }
client/components/AiResponse/MarkdownRenderer.tsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CodeHighlight } from "@mantine/code-highlight";
2
+ import { Blockquote, Box, Code, Divider, Text } from "@mantine/core";
3
+ import React from "react";
4
+ import { ErrorBoundary } from "react-error-boundary";
5
+ import Markdown from "react-markdown";
6
+ import rehypeExternalLinks from "rehype-external-links";
7
+ import remarkGfm from "remark-gfm";
8
+ import ExpandableLink from "./ExpandableLink";
9
+
10
+ interface MarkdownRendererProps {
11
+ content: string;
12
+ enableCopy?: boolean;
13
+ className?: string;
14
+ }
15
+
16
+ export default function MarkdownRenderer({
17
+ content,
18
+ enableCopy = true,
19
+ className = "",
20
+ }: MarkdownRendererProps) {
21
+ if (!content) {
22
+ return null;
23
+ }
24
+
25
+ const unwrapParagraphs = (children: React.ReactNode) => {
26
+ return React.Children.map(children, (child) => {
27
+ if (React.isValidElement(child) && child.type === "p") {
28
+ return (child.props as { children: React.ReactNode }).children;
29
+ }
30
+ return child;
31
+ });
32
+ };
33
+
34
+ return (
35
+ <Box className={className}>
36
+ <Markdown
37
+ remarkPlugins={[remarkGfm]}
38
+ rehypePlugins={[
39
+ [
40
+ rehypeExternalLinks,
41
+ { target: "_blank", rel: ["nofollow", "noopener", "noreferrer"] },
42
+ ],
43
+ ]}
44
+ components={{
45
+ a(props) {
46
+ const { href, children } = props;
47
+ return (
48
+ <ExpandableLink href={href || ""}>{children}</ExpandableLink>
49
+ );
50
+ },
51
+ li(props) {
52
+ const { children } = props;
53
+ return <li>{unwrapParagraphs(children)}</li>;
54
+ },
55
+ hr() {
56
+ return <Divider variant="dashed" my="md" />;
57
+ },
58
+ pre(props) {
59
+ return <>{props.children}</>;
60
+ },
61
+ blockquote(props) {
62
+ const { children } = props;
63
+ return (
64
+ <Blockquote>
65
+ <Text size="md">{unwrapParagraphs(children)}</Text>
66
+ </Blockquote>
67
+ );
68
+ },
69
+ code(props) {
70
+ const { children, className, node } = props;
71
+ const codeContent = children?.toString().replace(/\n$/, "") ?? "";
72
+ let language = "text";
73
+
74
+ if (className) {
75
+ const languageMatch = /language-(\w+)/.exec(className);
76
+ if (languageMatch) language = languageMatch[1];
77
+ }
78
+
79
+ if (
80
+ language === "text" &&
81
+ node?.position?.end.line === node?.position?.start.line
82
+ ) {
83
+ return <Code>{codeContent}</Code>;
84
+ }
85
+
86
+ return (
87
+ <ErrorBoundary fallback={<Code block>{codeContent}</Code>}>
88
+ <CodeHighlight
89
+ code={codeContent}
90
+ language={language}
91
+ radius="md"
92
+ withCopyButton={enableCopy}
93
+ mb="xs"
94
+ />
95
+ </ErrorBoundary>
96
+ );
97
+ },
98
+ }}
99
+ >
100
+ {content}
101
+ </Markdown>
102
+ </Box>
103
+ );
104
+ }
client/components/AiResponse/MessageList.tsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ActionIcon, Group, Paper, Stack, Tooltip } from "@mantine/core";
2
+ import { IconPencil, IconRefresh } from "@tabler/icons-react";
3
+ import { memo } from "react";
4
+ import type { ChatMessage } from "@/modules/types";
5
+ import FormattedMarkdown from "./FormattedMarkdown";
6
+
7
+ interface MessageListProps {
8
+ messages: ChatMessage[];
9
+ onEditMessage: (absoluteIndex: number) => void;
10
+ onRegenerate: () => void;
11
+ isGenerating: boolean;
12
+ }
13
+
14
+ interface MessageProps {
15
+ message: ChatMessage;
16
+ index: number;
17
+ absoluteIndex: number;
18
+ isLastAssistant: boolean;
19
+ isGenerating: boolean;
20
+ onEditMessage: (absoluteIndex: number) => void;
21
+ onRegenerate: () => void;
22
+ }
23
+
24
+ const Message = memo(
25
+ ({
26
+ message,
27
+ index,
28
+ absoluteIndex,
29
+ isLastAssistant,
30
+ isGenerating,
31
+ onEditMessage,
32
+ onRegenerate,
33
+ }: MessageProps) => {
34
+ const canEdit = message.role === "user";
35
+ const canRegenerate = isLastAssistant && message.role === "assistant";
36
+ const iconSize = 16;
37
+ const iconVariant: "subtle" = "subtle";
38
+
39
+ return (
40
+ <Group
41
+ gap="xs"
42
+ align="center"
43
+ w="100%"
44
+ justify={message.role === "user" ? "flex-end" : "flex-start"}
45
+ >
46
+ {canEdit && (
47
+ <Tooltip label="Edit" withArrow position="right" openDelay={300}>
48
+ <ActionIcon
49
+ aria-label="Edit message"
50
+ color="gray"
51
+ variant={iconVariant}
52
+ disabled={isGenerating}
53
+ onClick={() => onEditMessage(absoluteIndex)}
54
+ >
55
+ <IconPencil size={iconSize} />
56
+ </ActionIcon>
57
+ </Tooltip>
58
+ )}
59
+
60
+ <Paper
61
+ key={`${message.role}-${index}`}
62
+ shadow="xs"
63
+ radius="xl"
64
+ p="sm"
65
+ style={{ flex: 1, overflow: "auto" }}
66
+ >
67
+ <FormattedMarkdown>{message.content}</FormattedMarkdown>
68
+ </Paper>
69
+
70
+ {canRegenerate && (
71
+ <Tooltip
72
+ label="Re-generate response"
73
+ withArrow
74
+ position="left"
75
+ openDelay={300}
76
+ >
77
+ <ActionIcon
78
+ aria-label="Re-generate response"
79
+ color="gray"
80
+ variant={iconVariant}
81
+ disabled={isGenerating}
82
+ onClick={() => onRegenerate()}
83
+ >
84
+ <IconRefresh size={iconSize} />
85
+ </ActionIcon>
86
+ </Tooltip>
87
+ )}
88
+ </Group>
89
+ );
90
+ },
91
+ );
92
+
93
+ const MessageList = memo(function MessageList({
94
+ messages,
95
+ onEditMessage,
96
+ onRegenerate,
97
+ isGenerating,
98
+ }: MessageListProps) {
99
+ if (messages.length <= 2) return null;
100
+
101
+ return (
102
+ <Stack gap="md">
103
+ {messages
104
+ .slice(2)
105
+ .filter((message) => message.content.length > 0)
106
+ .map((message, index) => {
107
+ const absoluteIndex = index + 2;
108
+ const isLastAssistant =
109
+ absoluteIndex === messages.length - 1 &&
110
+ message.role === "assistant";
111
+ return (
112
+ <Message
113
+ key={`${message.role}-${message.content.slice(0, 50)}`}
114
+ message={message}
115
+ index={index}
116
+ absoluteIndex={absoluteIndex}
117
+ isLastAssistant={isLastAssistant}
118
+ isGenerating={isGenerating}
119
+ onEditMessage={onEditMessage}
120
+ onRegenerate={onRegenerate}
121
+ />
122
+ );
123
+ })}
124
+ </Stack>
125
+ );
126
+ });
127
+
128
+ export default MessageList;
client/components/AiResponse/PreparingContent.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Card, Skeleton, Stack, Text } from "@mantine/core";
2
+
3
+ export default function PreparingContent({
4
+ textGenerationState,
5
+ }: {
6
+ textGenerationState: string;
7
+ }) {
8
+ const getStateText = () => {
9
+ if (textGenerationState === "awaitingSearchResults") {
10
+ return "Awaiting search results...";
11
+ }
12
+ if (textGenerationState === "preparingToGenerate") {
13
+ return "Preparing AI response...";
14
+ }
15
+ return null;
16
+ };
17
+
18
+ return (
19
+ <Card withBorder shadow="sm" radius="md">
20
+ <Card.Section withBorder inheritPadding py="xs">
21
+ <Text fw={500}>{getStateText()}</Text>
22
+ </Card.Section>
23
+ <Card.Section withBorder inheritPadding py="md">
24
+ <Stack>
25
+ <Skeleton height={8} radius="xl" />
26
+ <Skeleton height={8} width="70%" radius="xl" />
27
+ <Skeleton height={8} radius="xl" />
28
+ <Skeleton height={8} width="43%" radius="xl" />
29
+ </Stack>
30
+ </Card.Section>
31
+ </Card>
32
+ );
33
+ }