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

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

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +25 -0
  2. .editorconfig +7 -0
  3. .env.example +33 -0
  4. .github/CODE_OF_CONDUCT.md +84 -0
  5. .github/CONTRIBUTING.md +130 -0
  6. .github/ISSUE_TEMPLATE/bug_report.md +123 -0
  7. .github/ISSUE_TEMPLATE/feature_request.md +73 -0
  8. .github/ISSUE_TEMPLATE/security_vulnerability.md +98 -0
  9. .github/PULL_REQUEST_TEMPLATE.md +44 -0
  10. .github/SECURITY.md +135 -0
  11. .github/hf-space-config.yml +11 -0
  12. .github/workflows/ci.yml +33 -0
  13. .github/workflows/deploy-to-hugging-face.yml +18 -0
  14. .github/workflows/on-pull-request-to-main.yml +9 -0
  15. .github/workflows/on-push-to-main.yml +7 -0
  16. .github/workflows/publish-docker-image.yml +39 -0
  17. .github/workflows/reusable-test-lint-ping.yml +25 -0
  18. .gitignore +9 -0
  19. .husky/pre-commit +2 -0
  20. .npmrc +1 -0
  21. Dockerfile +96 -0
  22. README.md +143 -0
  23. agents.md +176 -0
  24. biome.json +34 -0
  25. client/components/AiResponse/AiModelDownloadAllowanceContent.tsx +62 -0
  26. client/components/AiResponse/AiResponseContent.tsx +219 -0
  27. client/components/AiResponse/AiResponseSection.tsx +101 -0
  28. client/components/AiResponse/ChatHeader.tsx +33 -0
  29. client/components/AiResponse/ChatInputArea.tsx +106 -0
  30. client/components/AiResponse/ChatInterface.tsx +436 -0
  31. client/components/AiResponse/CopyIconButton.tsx +32 -0
  32. client/components/AiResponse/EnableAiResponsePrompt.tsx +85 -0
  33. client/components/AiResponse/ExpandableLink.tsx +123 -0
  34. client/components/AiResponse/FormattedMarkdown.tsx +41 -0
  35. client/components/AiResponse/LoadingModelContent.tsx +40 -0
  36. client/components/AiResponse/MarkdownRenderer.tsx +104 -0
  37. client/components/AiResponse/MessageList.tsx +128 -0
  38. client/components/AiResponse/PreparingContent.tsx +33 -0
  39. client/components/AiResponse/ReasoningSection.tsx +79 -0
  40. client/components/AiResponse/WebLlmModelSelect.tsx +81 -0
  41. client/components/AiResponse/WllamaModelSelect.tsx +42 -0
  42. client/components/AiResponse/hooks/useReasoningContent.test.ts +96 -0
  43. client/components/AiResponse/hooks/useReasoningContent.ts +69 -0
  44. client/components/Analytics/SearchStats.tsx +313 -0
  45. client/components/App/App.tsx +123 -0
  46. client/components/Logs/LogsModal.tsx +136 -0
  47. client/components/Logs/ShowLogsButton.tsx +42 -0
  48. client/components/Pages/AccessPage.tsx +70 -0
  49. client/components/Pages/Main/MainPage.test.tsx +110 -0
  50. client/components/Pages/Main/MainPage.tsx +81 -0
.dockerignore ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
25
+
.editorconfig ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ [*]
2
+ charset = utf-8
3
+ insert_final_newline = true
4
+ end_of_line = lf
5
+ indent_style = space
6
+ indent_size = 2
7
+ max_line_length = 80
.env.example ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A comma-separated list of access keys. Example: `ACCESS_KEYS="ABC123,JUD71F,HUWE3"`. Leave blank for unrestricted access.
2
+ ACCESS_KEYS=""
3
+
4
+ # The timeout in hours for access key validation. Set to 0 to require validation on every page load.
5
+ ACCESS_KEY_TIMEOUT_HOURS="24"
6
+
7
+ # The default model ID for WebLLM with F16 shaders.
8
+ WEBLLM_DEFAULT_F16_MODEL_ID="Qwen3-0.6B-q4f16_1-MLC"
9
+
10
+ # The default model ID for WebLLM with F32 shaders.
11
+ WEBLLM_DEFAULT_F32_MODEL_ID="Qwen3-0.6B-q4f32_1-MLC"
12
+
13
+ # The default model ID for Wllama.
14
+ WLLAMA_DEFAULT_MODEL_ID="qwen-3-0.6b"
15
+
16
+ # The base URL for the internal OpenAI compatible API. Example: `INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL="https://api.openai.com/v1"`. Leave blank to disable internal OpenAI compatible API.
17
+ INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL=""
18
+
19
+ # The access key for the internal OpenAI compatible API.
20
+ INTERNAL_OPENAI_COMPATIBLE_API_KEY=""
21
+
22
+ # The model for the internal OpenAI compatible API.
23
+ INTERNAL_OPENAI_COMPATIBLE_API_MODEL=""
24
+
25
+ # The name of the internal OpenAI compatible API, displayed in the UI.
26
+ INTERNAL_OPENAI_COMPATIBLE_API_NAME="Internal API"
27
+
28
+ # The type of inference to use by default. The possible values are:
29
+ # "browser" -> In the browser (Private)
30
+ # "openai" -> Remote Server (API)
31
+ # "horde" -> AI Horde (Pre-configured)
32
+ # "internal" -> $INTERNAL_OPENAI_COMPATIBLE_API_NAME
33
+ DEFAULT_INFERENCE_TYPE="browser"
.github/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,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ## How to Contribute
6
+
7
+ ### Reporting Bugs
8
+
9
+ - Use the [GitHub issue tracker](https://github.com/felladrin/MiniSearch/issues) to report bugs
10
+ - Check if the issue has already been reported before creating a new one
11
+ - Provide clear steps to reproduce the issue
12
+ - Include your environment details (OS, browser, Docker version if applicable)
13
+ - Add screenshots if the issue is UI-related
14
+
15
+ ### Suggesting Features
16
+
17
+ - Use the [GitHub issue tracker](https://github.com/felladrin/MiniSearch/issues) for feature suggestions
18
+ - Clearly describe the feature and why it would be useful
19
+ - Consider if it fits with the project's minimalist philosophy
20
+ - Provide examples of how you envision the feature working
21
+
22
+ ### Development Setup
23
+
24
+ 1. Fork the repository and clone it locally
25
+ 2. Make sure you have [Docker](https://docs.docker.com/get-docker/) installed
26
+ 3. Start the development server:
27
+ ```bash
28
+ docker compose up
29
+ ```
30
+ 4. The application will be available at http://localhost:7860
31
+ 5. Make your changes
32
+ 6. Test your changes thoroughly
33
+ 7. Push to your fork and create a pull request
34
+
35
+ ### Running Tests
36
+
37
+ ```bash
38
+ docker compose exec development-server npm run test
39
+ ```
40
+
41
+ For coverage:
42
+ ```bash
43
+ docker compose exec development-server npm run test:coverage
44
+ ```
45
+
46
+ ### Code Quality
47
+
48
+ Before submitting a pull request, please run:
49
+ ```bash
50
+ docker compose exec development-server npm run lint
51
+ ```
52
+
53
+ This runs:
54
+ - Biome (formatting/linting)
55
+ - TypeScript (type checking)
56
+ - ts-prune (dead code detection)
57
+ - jscpd (copy-paste detection)
58
+ - dpdm (circular dependency detection)
59
+ - Custom architectural linter
60
+
61
+ ## Contribution Guidelines
62
+
63
+ ### Types of Contributions We're Looking For
64
+
65
+ - **Bug fixes**: Always welcome!
66
+ - **Documentation improvements**: Better docs help everyone
67
+ - **Performance optimizations**: Especially for search and AI features
68
+ - **UI/UX improvements**: Keeping the minimalist yet intuitive design
69
+ - **New AI model integrations**: Following existing patterns
70
+ - **Security enhancements**: Following security best practices
71
+
72
+ ### Project Vision
73
+
74
+ MiniSearch aims to be a minimalist, privacy-focused web search application with AI assistance. We prioritize:
75
+ - Privacy and security
76
+ - Simplicity and ease of use
77
+ - Cross-platform compatibility
78
+ - Efficient resource usage
79
+
80
+ ### Getting in Touch
81
+
82
+ - For questions about contributions: Use GitHub discussions
83
+ - For security issues: See [SECURITY.md](SECURITY.md)
84
+ - For general questions: Use GitHub issues
85
+
86
+ ## Pull Request Process
87
+
88
+ 1. Fork the repository
89
+ 2. Create a feature branch (`git checkout -b feature/amazing-feature`)
90
+ 3. Make your changes
91
+ 4. Add tests if applicable
92
+ 5. Ensure all tests pass and linting succeeds
93
+ 6. Commit your changes (`git commit -m 'Add some amazing feature'`)
94
+ 7. Push to the branch (`git push origin feature/amazing-feature`)
95
+ 8. Open a Pull Request
96
+
97
+ ### Pull Request Guidelines
98
+
99
+ - Keep PRs focused on a single feature or fix
100
+ - Write clear commit messages
101
+ - Update documentation if needed
102
+ - Ensure your code follows the existing style
103
+ - Be responsive to feedback and reviews
104
+
105
+ ## Development Commands
106
+
107
+ ```bash
108
+ # Start development server
109
+ docker compose up
110
+
111
+ # Run tests
112
+ docker compose exec development-server npm run test
113
+
114
+ # Run linting
115
+ docker compose exec development-server npm run lint
116
+
117
+ # Build for production
118
+ docker compose -f docker-compose.production.yml build
119
+
120
+ # View test coverage
121
+ docker compose exec development-server npm run test:coverage
122
+ ```
123
+
124
+ ## Questions?
125
+
126
+ Don't hesitate to ask questions! We're here to help you contribute successfully.
127
+
128
+ ---
129
+
130
+ Thanks again for your interest in contributing to MiniSearch! 🎉
.github/ISSUE_TEMPLATE/bug_report.md 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/feature_request.md 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.md ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 for security vulnerabilities, please use one of these private reporting methods:
14
+
15
+ 1. **Preferred**: Use GitHub's [Private Vulnerability Reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability)
16
+ 2. **Alternative**: Email the maintainer privately
17
+
18
+ If you accidentally created a public issue, please close it and report privately instead.
19
+
20
+ - type: checkboxes
21
+ id: confirmation
22
+ attributes:
23
+ label: Confirmation
24
+ description: Please confirm you understand the security reporting process
25
+ options:
26
+ - label: I understand this should be reported privately and will use the methods above
27
+ required: true
28
+
29
+ - type: dropdown
30
+ id: vulnerability-type
31
+ attributes:
32
+ label: Vulnerability Type
33
+ description: What type of security vulnerability is this?
34
+ options:
35
+ - Cross-site Scripting (XSS)
36
+ - Authentication/Authorization Bypass
37
+ - Information Disclosure
38
+ - Remote Code Execution
39
+ - Denial of Service
40
+ - Configuration Issue
41
+ - Other
42
+ validations:
43
+ required: true
44
+
45
+ - type: dropdown
46
+ id: severity
47
+ attributes:
48
+ label: Severity
49
+ description: How severe is this vulnerability?
50
+ options:
51
+ - Critical
52
+ - High
53
+ - Medium
54
+ - Low
55
+ validations:
56
+ required: true
57
+
58
+ - type: textarea
59
+ id: description
60
+ attributes:
61
+ label: Description
62
+ description: A clear description of the security vulnerability
63
+ validations:
64
+ required: true
65
+
66
+ - type: textarea
67
+ id: steps-to-reproduce
68
+ attributes:
69
+ label: Steps to Reproduce
70
+ description: Detailed steps to reproduce the vulnerability
71
+ validations:
72
+ required: true
73
+
74
+ - type: textarea
75
+ id: impact
76
+ attributes:
77
+ label: Impact
78
+ description: Describe the potential impact of this vulnerability
79
+ validations:
80
+ required: true
81
+
82
+ - type: textarea
83
+ id: mitigation
84
+ attributes:
85
+ label: Mitigation (if known)
86
+ description: Any known workarounds or mitigations
87
+
88
+ - type: textarea
89
+ id: additional-information
90
+ attributes:
91
+ label: Additional Information
92
+ description: Any additional context or information about the vulnerability
93
+
94
+ - type: markdown
95
+ attributes:
96
+ value: |
97
+ ## 🚨 PLEASE DO NOT SUBMIT THIS PUBLICLY - USE PRIVATE REPORTING METHODS ABOVE 🚨
98
+ ---
.github/PULL_REQUEST_TEMPLATE.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## Description
2
+ Brief description of what this PR changes.
3
+
4
+ ## Type of Change
5
+ - [ ] Bug fix (non-breaking change that fixes an issue)
6
+ - [ ] New feature (non-breaking change that adds functionality)
7
+ - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
8
+ - [ ] Documentation update
9
+ - [ ] Security fix
10
+ - [ ] Performance improvement
11
+ - [ ] Code quality improvement
12
+
13
+ ## Testing
14
+ - [ ] I have tested this change locally
15
+ - [ ] I have added tests for this change
16
+ - [ ] All existing tests pass
17
+ - [ ] I have run the linter and it passes (`npm run lint`)
18
+
19
+ ## Checklist
20
+ - [ ] My code follows the project's coding conventions
21
+ - [ ] I have reviewed my own code
22
+ - [ ] I have made corresponding changes to the documentation
23
+ - [ ] My changes generate no new warnings
24
+ - [ ] I have added tests that prove my fix is effective or that my feature works
25
+ - [ ] New and existing unit tests pass locally with my changes
26
+ - [ ] Any dependent changes have been merged and published in downstream modules
27
+
28
+ ## Screenshots (if applicable)
29
+ Add screenshots to help explain your changes, especially for UI modifications.
30
+
31
+ ## Additional Context
32
+ Add any other context about the problem here.
33
+
34
+ ## Security Considerations
35
+ - [ ] This change does not introduce any security vulnerabilities
36
+ - [ ] I have considered the security implications of this change
37
+ - [ ] If this change handles user input, it has been properly sanitized/validated
38
+
39
+ ## Performance Impact
40
+ - [ ] This change does not negatively impact performance
41
+ - [ ] I have tested the performance impact of this change
42
+
43
+ ## Breaking Changes
44
+ If this PR introduces breaking changes, please describe them here and how users should migrate.
.github/SECURITY.md ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ We strongly encourage using 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 to report security vulnerabilities.
17
+
18
+ **Do not report security vulnerabilities through public issues.**
19
+
20
+ ### How to Report
21
+
22
+ 1. **Preferred**: Use GitHub's Private Vulnerability Reporting
23
+ 2. **Alternative**: Email the maintainer privately at: (contact can be provided upon request)
24
+
25
+ When reporting a vulnerability, please include:
26
+ - A clear description of the vulnerability
27
+ - Steps to reproduce the issue
28
+ - Potential impact of the vulnerability
29
+ - Any suggested mitigations (if known)
30
+
31
+ ### What to Expect
32
+
33
+ - We will acknowledge receipt of your report within 48 hours
34
+ - We will provide a detailed response within 7 days
35
+ - We will work with you to understand and validate the report
36
+ - We will coordinate disclosure timing to minimize user risk
37
+
38
+ ## Security Scope
39
+
40
+ ### In Scope
41
+
42
+ - Vulnerabilities in the MiniSearch web application
43
+ - Security issues in the Docker container configuration
44
+ - Authentication and authorization bypasses
45
+ - Cross-site scripting (XSS) vulnerabilities
46
+ - Information disclosure issues
47
+ - Remote code execution vulnerabilities
48
+ - Privilege escalation in the application context
49
+
50
+ ### Out of Scope
51
+
52
+ - Issues in third-party dependencies (report to respective projects)
53
+ - Vulnerabilities in the underlying browser or Node.js runtime
54
+ - Physical attacks on infrastructure
55
+ - Social engineering attacks
56
+ - Denial of service attacks that don't indicate a vulnerability
57
+ - Issues requiring physical access to user devices
58
+
59
+ ## Threat Model
60
+
61
+ ### MiniSearch's Security Boundaries
62
+
63
+ MiniSearch is designed as a privacy-focused search application with the following security assumptions:
64
+
65
+ **Trust Boundaries:**
66
+ - **Browser Environment**: The application runs entirely in the user's browser
67
+ - **Server Component**: Optional backend for search and AI processing
68
+ - **AI Models**: Local or remote AI processing with configurable endpoints
69
+
70
+ **Data Flow:**
71
+ - User queries are sent to SearXNG instances (configurable)
72
+ - AI processing can be local (WebLLM/Wllama) or remote (API endpoints)
73
+ - Search history is stored locally in the browser
74
+ - No tracking or analytics by default
75
+
76
+ **Security Controls:**
77
+ - Optional access key protection for deployment
78
+ - Configurable AI endpoints for privacy
79
+ - Local-first data storage
80
+ - No third-party tracking or analytics
81
+
82
+ **Potential Risks:**
83
+ - Malicious SearXNG instances could log queries
84
+ - Remote AI endpoints could access user queries
85
+ - Browser extensions could interfere with the application
86
+ - Man-in-the-middle attacks without HTTPS
87
+
88
+ ## Security Best Practices
89
+
90
+ ### For Users
91
+
92
+ - Always use HTTPS when accessing MiniSearch instances
93
+ - Configure trusted SearXNG instances
94
+ - Use local AI models for maximum privacy
95
+ - Set access keys for deployed instances
96
+ - Keep browsers updated
97
+
98
+ ### For Deployers
99
+
100
+ - Use the official Docker image
101
+ - Configure environment variables securely
102
+ - Set up proper access controls
103
+ - Use HTTPS in production
104
+ - Regularly update dependencies
105
+ - Monitor for security advisories
106
+
107
+ ## Security Features
108
+
109
+ - **Access Key Protection**: Optional password-based access control
110
+ - **Configurable Endpoints**: Users control search and AI providers
111
+ - **Local Processing**: AI models can run entirely in the browser
112
+ - **No Tracking**: Built without analytics or tracking
113
+ - **HTTPS Ready**: Designed for secure deployment
114
+
115
+ ## Security Updates
116
+
117
+ Security updates will be:
118
+ - Released as new versions
119
+ - Announced in release notes
120
+ - Coordinated with dependency updates when applicable
121
+
122
+ ## Security Team
123
+
124
+ The MiniSearch security team is currently the project maintainer:
125
+ - [@felladrin](https://github.com/felladrin) - Project Maintainer
126
+
127
+ ## Acknowledgments
128
+
129
+ We thank security researchers who help us keep MiniSearch secure. All valid security reports will be acknowledged in our release notes (with reporter permission).
130
+
131
+ ## Related Resources
132
+
133
+ - [GitHub Security Advisories](https://docs.github.com/en/code-security/security-advisories)
134
+ - [OWASP Web Security Testing Guide](https://owasp.org/www-project-web-security-testing-guide/)
135
+ - [Mozilla Security Guidelines](https://infosec.mozilla.org/guidelines)
.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,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master]
6
+ pull_request:
7
+ branches: [main, master]
8
+
9
+ jobs:
10
+ build-test:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - name: Checkout repository
15
+ uses: actions/checkout@v6
16
+
17
+ - name: Set up Node.js
18
+ uses: actions/setup-node@v6
19
+ with:
20
+ node-version: lts/*
21
+ cache: npm
22
+
23
+ - name: Install dependencies
24
+ run: npm ci
25
+
26
+ - name: Run lint
27
+ run: npm run lint
28
+
29
+ - name: Check formatting
30
+ run: npm run format
31
+
32
+ - name: Run tests
33
+ run: npm test
.github/workflows/deploy-to-hugging-face.yml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to Hugging Face
2
+
3
+ on:
4
+ workflow_dispatch:
5
+
6
+ jobs:
7
+ sync-to-hf:
8
+ name: Sync to Hugging Face Spaces
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v6
12
+ - uses: JacobLinCool/huggingface-sync@v1
13
+ with:
14
+ github: ${{ secrets.GITHUB_TOKEN }}
15
+ user: ${{ vars.HF_SPACE_OWNER }}
16
+ space: ${{ vars.HF_SPACE_NAME }}
17
+ token: ${{ secrets.HF_TOKEN }}
18
+ configuration: ".github/hf-space-config.yml"
.github/workflows/on-pull-request-to-main.yml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ name: On Pull Request To Main
2
+ on:
3
+ pull_request:
4
+ types: [opened, synchronize, reopened]
5
+ branches: ["main"]
6
+ jobs:
7
+ test-lint-ping:
8
+ if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-test-lint-ping') }}
9
+ uses: ./.github/workflows/reusable-test-lint-ping.yml
.github/workflows/on-push-to-main.yml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ name: On Push To Main
2
+ on:
3
+ push:
4
+ branches: ["main"]
5
+ jobs:
6
+ test-lint-ping:
7
+ uses: ./.github/workflows/reusable-test-lint-ping.yml
.github/workflows/publish-docker-image.yml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish Docker Image
2
+
3
+ on:
4
+ workflow_dispatch:
5
+
6
+ jobs:
7
+ build-and-push-image:
8
+ name: Publish Docker Image to GitHub Packages
9
+ runs-on: ubuntu-latest
10
+ env:
11
+ REGISTRY: ghcr.io
12
+ IMAGE_NAME: ${{ github.repository }}
13
+ permissions:
14
+ contents: read
15
+ packages: write
16
+ steps:
17
+ - name: Checkout repository
18
+ uses: actions/checkout@v6
19
+ - name: Log in to the Container registry
20
+ uses: docker/login-action@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@v6
28
+ with:
29
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
30
+ - name: Set up Docker Buildx
31
+ uses: docker/setup-buildx-action@v4
32
+ - name: Build and push Docker Image
33
+ uses: docker/build-push-action@v7
34
+ with:
35
+ context: .
36
+ push: true
37
+ tags: ${{ steps.meta.outputs.tags }}
38
+ labels: ${{ steps.meta.outputs.labels }}
39
+ platforms: linux/amd64,linux/arm64
.github/workflows/reusable-test-lint-ping.yml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ on:
2
+ workflow_call:
3
+ jobs:
4
+ check-code-quality:
5
+ name: Check Code Quality
6
+ runs-on: ubuntu-latest
7
+ steps:
8
+ - uses: actions/checkout@v6
9
+ - uses: actions/setup-node@v6
10
+ with:
11
+ node-version: "lts/*"
12
+ cache: "npm"
13
+ - run: npm ci --ignore-scripts
14
+ - run: npm run lint
15
+ check-docker-container:
16
+ needs: [check-code-quality]
17
+ name: Check Docker Container
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v6
21
+ - run: docker compose -f docker-compose.production.yml up -d
22
+ - name: Check if main page is available
23
+ run: until curl -s -o /dev/null -w "%{http_code}" localhost:7860 | grep 200; do sleep 1; done
24
+ timeout-minutes: 1
25
+ - run: docker compose -f docker-compose.production.yml down
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .DS_Store
3
+ /client/dist
4
+ /server/models
5
+ .vscode
6
+ /vite-build-stats.html
7
+ .env
8
+ /coverage
9
+ .playwright-cli
.husky/pre-commit ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ #!/usr/bin/env sh
2
+ npm install --no-save @biomejs/biome && npx @biomejs/biome check --write --staged --no-errors-on-unmatched && git diff --name-only --cached | while read -r file; do git add "$file"; done
.npmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ legacy-peer-deps = true
Dockerfile ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:lts AS llama-builder
2
+
3
+ ARG LLAMA_CPP_RELEASE_TAG="b6604"
4
+
5
+ RUN apt-get update && apt-get install -y \
6
+ build-essential \
7
+ cmake \
8
+ ccache \
9
+ git \
10
+ curl
11
+
12
+ RUN cd /tmp && \
13
+ git clone https://github.com/ggerganov/llama.cpp.git && \
14
+ cd llama.cpp && \
15
+ git checkout $LLAMA_CPP_RELEASE_TAG && \
16
+ cmake -B build -DGGML_NATIVE=OFF -DLLAMA_CURL=OFF && \
17
+ cmake --build build --config Release -j --target llama-server && \
18
+ mkdir -p /usr/local/lib/llama && \
19
+ find build -type f \( -name "libllama.so" -o -name "libmtmd.so" -o -name "libggml.so" -o -name "libggml-base.so" -o -name "libggml-cpu.so" \) -exec cp {} /usr/local/lib/llama/ \;
20
+
21
+ FROM node:lts
22
+
23
+ ENV PORT=7860
24
+ EXPOSE $PORT
25
+
26
+ ARG USERNAME=node
27
+ ARG HOME_DIR=/home/${USERNAME}
28
+ ARG APP_DIR=${HOME_DIR}/app
29
+
30
+ RUN apt-get update && \
31
+ apt-get install -y --no-install-recommends \
32
+ python3 \
33
+ python3-venv && \
34
+ apt-get clean && \
35
+ rm -rf /var/lib/apt/lists/*
36
+
37
+ RUN mkdir -p /usr/local/searxng /etc/searxng && \
38
+ chown -R ${USERNAME}:${USERNAME} /usr/local/searxng /etc/searxng && \
39
+ chmod 755 /etc/searxng
40
+
41
+ WORKDIR /usr/local/searxng
42
+ RUN python3 -m venv searxng-venv && \
43
+ chown -R ${USERNAME}:${USERNAME} /usr/local/searxng/searxng-venv && \
44
+ /usr/local/searxng/searxng-venv/bin/pip install --upgrade pip && \
45
+ /usr/local/searxng/searxng-venv/bin/pip install wheel setuptools pyyaml lxml
46
+
47
+ RUN git clone https://github.com/searxng/searxng.git /usr/local/searxng/searxng-src && \
48
+ chown -R ${USERNAME}:${USERNAME} /usr/local/searxng/searxng-src
49
+
50
+ ARG SEARXNG_SETTINGS_PATH="/etc/searxng/settings.yml"
51
+
52
+ WORKDIR /usr/local/searxng/searxng-src
53
+ RUN cp searx/settings.yml $SEARXNG_SETTINGS_PATH && \
54
+ chown ${USERNAME}:${USERNAME} $SEARXNG_SETTINGS_PATH && \
55
+ chmod 644 $SEARXNG_SETTINGS_PATH && \
56
+ sed -i 's/ultrasecretkey/'$(openssl rand -hex 32)'/g' $SEARXNG_SETTINGS_PATH && \
57
+ sed -i 's/- html/- json/' $SEARXNG_SETTINGS_PATH && \
58
+ /usr/local/searxng/searxng-venv/bin/pip install -r requirements.txt && \
59
+ /usr/local/searxng/searxng-venv/bin/pip install --no-build-isolation -e .
60
+
61
+ COPY --from=llama-builder /tmp/llama.cpp/build/bin/llama-server /usr/local/bin/
62
+ COPY --from=llama-builder /usr/local/lib/llama/* /usr/local/lib/
63
+ RUN ldconfig /usr/local/lib
64
+
65
+ USER ${USERNAME}
66
+
67
+ WORKDIR ${APP_DIR}
68
+
69
+ ARG ACCESS_KEYS
70
+ ARG ACCESS_KEY_TIMEOUT_HOURS
71
+ ARG WEBLLM_DEFAULT_F16_MODEL_ID
72
+ ARG WEBLLM_DEFAULT_F32_MODEL_ID
73
+ ARG WLLAMA_DEFAULT_MODEL_ID
74
+ ARG INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL
75
+ ARG INTERNAL_OPENAI_COMPATIBLE_API_KEY
76
+ ARG INTERNAL_OPENAI_COMPATIBLE_API_MODEL
77
+ ARG INTERNAL_OPENAI_COMPATIBLE_API_NAME
78
+ ARG DEFAULT_INFERENCE_TYPE
79
+ ARG HOST
80
+ ARG HMR_PORT
81
+ ARG ALLOWED_HOSTS
82
+
83
+ COPY --chown=${USERNAME}:${USERNAME} ./package.json ./package-lock.json ./.npmrc ./
84
+
85
+ RUN npm ci
86
+
87
+ COPY --chown=${USERNAME}:${USERNAME} . .
88
+
89
+ RUN git config --global --add safe.directory ${APP_DIR} && \
90
+ npm run build
91
+
92
+ HEALTHCHECK --interval=5m CMD curl -f http://localhost:7860/status || exit 1
93
+
94
+ ENTRYPOINT [ "/bin/sh", "-c" ]
95
+
96
+ CMD ["(cd /usr/local/searxng/searxng-src && /usr/local/searxng/searxng-venv/bin/python -m searx.webapp > /dev/null 2>&1) & npm start -- --host"]
README.md ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: MiniSearch
3
+ emoji: 👌🔍
4
+ colorFrom: yellow
5
+ colorTo: yellow
6
+ sdk: docker
7
+ short_description: Minimalist web-searching app with browser-based AI assistant
8
+ pinned: true
9
+ custom_headers:
10
+ cross-origin-embedder-policy: require-corp
11
+ cross-origin-opener-policy: same-origin
12
+ cross-origin-resource-policy: cross-origin
13
+ ---
14
+
15
+ # MiniSearch
16
+
17
+ A minimalist web-searching app with an AI assistant that runs directly from your browser.
18
+
19
+ Live demo: https://felladrin-minisearch.hf.space
20
+
21
+ ## Screenshot
22
+
23
+ ![MiniSearch Screenshot](https://github.com/user-attachments/assets/f8d72a8e-a725-42e9-9358-e6ebade2acb2)
24
+
25
+ ## Features
26
+
27
+ - **Privacy-focused**: [No tracking, no ads, no data collection](https://docs.searxng.org/own-instance.html#how-does-searxng-protect-privacy)
28
+ - **Easy to use**: Minimalist yet intuitive interface for all users
29
+ - **Cross-platform**: Models run inside the browser, both on desktop and mobile
30
+ - **Integrated**: Search from the browser address bar by setting it as the default search engine
31
+ - **Efficient**: Models are loaded and cached only when needed
32
+ - **Customizable**: Tweakable settings for search results and text generation
33
+ - **Open-source**: [The code is available for inspection and contribution at GitHub](https://github.com/felladrin/MiniSearch)
34
+
35
+ ## Prerequisites
36
+
37
+ - [Docker](https://docs.docker.com/get-docker/)
38
+
39
+ ## Getting started
40
+
41
+ Here are the easiest ways to get started with MiniSearch. Pick the one that suits you best.
42
+
43
+ **Option 1** - Use [MiniSearch's Docker Image](https://github.com/felladrin/MiniSearch/pkgs/container/minisearch) by running in your terminal:
44
+
45
+ ```bash
46
+ docker run -p 7860:7860 ghcr.io/felladrin/minisearch:main
47
+ ```
48
+
49
+ **Option 2** - Add MiniSearch's Docker Image to your existing Docker Compose file:
50
+
51
+ ```yaml
52
+ services:
53
+ minisearch:
54
+ image: ghcr.io/felladrin/minisearch:main
55
+ ports:
56
+ - "7860:7860"
57
+ ```
58
+
59
+ **Option 3** - Build from source by [downloading the repository files](https://github.com/felladrin/MiniSearch/archive/refs/heads/main.zip) and running:
60
+
61
+ ```bash
62
+ docker compose -f docker-compose.production.yml up --build
63
+ ```
64
+
65
+ Once the container is running, open http://localhost:7860 in your browser and start searching!
66
+
67
+ ## Frequently asked questions [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/felladrin/MiniSearch)
68
+
69
+ <details>
70
+ <summary>How do I search via the browser's address bar?</summary>
71
+ <p>
72
+ You can set MiniSearch as your browser's address-bar search engine using the pattern <code>http://localhost:7860/?q=%s</code>, in which your search term replaces <code>%s</code>.
73
+ </p>
74
+ </details>
75
+
76
+ <details>
77
+ <summary>How do I search via Raycast?</summary>
78
+ <p>
79
+ You can add <a href="https://ray.so/quicklinks/shared?quicklinks=%7B%22link%22:%22https:%5C/%5C/felladrin-minisearch.hf.space%5C/?q%3D%7BQuery%7D%22,%22name%22:%22MiniSearch%22%7D" target="_blank">this Quicklink</a> to Raycast, so typing your query will open MiniSearch with the search results. You can also edit it to point to your own domain.
80
+ </p>
81
+ <img width="744" alt="image" src="https://github.com/user-attachments/assets/521dca22-c77b-42de-8cc8-9feb06f9a97e">
82
+ </details>
83
+
84
+ <details>
85
+ <summary>Can I use custom models via OpenAI-Compatible API?</summary>
86
+ <p>
87
+ Yes! For this, open the Menu and change the "AI Processing Location" to <code>Remote server (API)</code>. Then configure the Base URL, and optionally set an API Key and a Model to use.
88
+ </p>
89
+ </details>
90
+
91
+ <details>
92
+ <summary>How do I restrict the access to my MiniSearch instance via password?</summary>
93
+ <p>
94
+ Create a <code>.env</code> file and set a value for <code>ACCESS_KEYS</code>. Then reset the MiniSearch docker container.
95
+ </p>
96
+ <p>
97
+ For example, if you to set the password to <code>PepperoniPizza</code>, then this is what you should add to your <code>.env</code>:<br/>
98
+ <code>ACCESS_KEYS="PepperoniPizza"</code>
99
+ </p>
100
+ <p>
101
+ You can find more examples in the <code>.env.example</code> file.
102
+ </p>
103
+ </details>
104
+
105
+ <details>
106
+ <summary>I want to serve MiniSearch to other users, allowing them to use my own OpenAI-Compatible API key, but without revealing it to them. Is it possible?</summary>
107
+ <p>Yes! In MiniSearch, we call this text-generation feature "Internal OpenAI-Compatible API". To use this it:</p>
108
+ <ol>
109
+ <li>Set up your OpenAI-Compatible API endpoint by configuring the following environment variables in your <code>.env</code> file:
110
+ <ul>
111
+ <li><code>INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL</code>: The base URL for your API</li>
112
+ <li><code>INTERNAL_OPENAI_COMPATIBLE_API_KEY</code>: Your API access key</li>
113
+ <li><code>INTERNAL_OPENAI_COMPATIBLE_API_MODEL</code>: The model to use</li>
114
+ <li><code>INTERNAL_OPENAI_COMPATIBLE_API_NAME</code>: The name to display in the UI</li>
115
+ </ul>
116
+ </li>
117
+ <li>Restart MiniSearch server.</li>
118
+ <li>In the MiniSearch menu, select the new option (named as per your <code>INTERNAL_OPENAI_COMPATIBLE_API_NAME</code> setting) from the "AI Processing Location" dropdown.</li>
119
+ </ol>
120
+ </details>
121
+
122
+ <details>
123
+ <summary>How can I contribute to the development of this tool?</summary>
124
+ <p>We welcome contributions! Please read our <a href=".github/CONTRIBUTING.md">Contributing Guidelines</a> for detailed information on how to get started.</p>
125
+ <p>Quick start:</p>
126
+ <ol>
127
+ <li>Fork this repository and clone it</li>
128
+ <li>Start the development server: <code>docker compose up</code></li>
129
+ <li>Make your changes and test them</li>
130
+ <li>Push to your fork and open a pull request</li>
131
+ </ol>
132
+ <p>All contributions are welcome! 🎉</p>
133
+ </details>
134
+
135
+ <details>
136
+ <summary>Where can I find more information?</summary>
137
+ <ul>
138
+ <li><a href=".github/CONTRIBUTING.md">Contributing Guidelines</a> - How to contribute to MiniSearch</li>
139
+ <li><a href=".github/CODE_OF_CONDUCT.md">Code of Conduct</a> - Our community guidelines</li>
140
+ <li><a href=".github/SECURITY.md">Security Policy</a> - How to report security vulnerabilities</li>
141
+ <li><a href="docs/">Documentation</a> - Detailed project documentation</li>
142
+ </ul>
143
+ </details>
agents.md ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MiniSearch Agent Guidelines
2
+
3
+ This is your navigation hub. Start here, follow the links, and return when you need orientation.
4
+
5
+ ## Before You Start
6
+
7
+ **New to this codebase?** Read in this order:
8
+ 1. `docs/quick-start.md` - Get it running
9
+ 2. `docs/overview.md` - Understand the system
10
+ 3. `docs/project-structure.md` - Navigate the code
11
+
12
+ **Making changes?** Check:
13
+ - `docs/coding-conventions.md` - Code style
14
+ - `docs/development-commands.md` - Available commands
15
+ - `docs/pull-requests.md` - How to submit
16
+
17
+ ## Repository Map
18
+
19
+ ### Getting Started
20
+ - **`docs/quick-start.md`** - Installation, first run, verification
21
+ - **`docs/overview.md`** - System architecture and data flow
22
+ - **`docs/project-structure.md`** - Directory layout and component organization
23
+
24
+ ### Configuration & Setup
25
+ - **`docs/configuration.md`** - Environment variables and settings reference
26
+ - **`docs/security.md`** - Access control, privacy, and security model
27
+
28
+ ### Core Functionality
29
+ - **`docs/ai-integration.md`** - AI inference types (WebLLM, Wllama, OpenAI, AI Horde, Internal)
30
+ - **`docs/ui-components.md`** - Component architecture and PubSub patterns
31
+ - **`docs/search-history.md`** - History database schema and management
32
+ - **`docs/conversation-memory.md`** - Token budgeting and rolling summaries
33
+
34
+ ### Development
35
+ - **`docs/development-commands.md`** - Docker, npm, and testing commands
36
+ - **`docs/coding-conventions.md`** - Style guide and patterns
37
+ - **`docs/pull-requests.md`** - PR process and merge philosophy
38
+ - **`docs/core-technologies.md`** - Technology stack and dependencies
39
+ - **`docs/design.md`** - UI/UX design principles
40
+
41
+ ## Agent Decision Tree
42
+
43
+ ```
44
+ Need to:
45
+ ├── Add a feature?
46
+ │ ├── UI component → docs/ui-components.md
47
+ │ ├── AI integration → docs/ai-integration.md
48
+ │ ├── Search functionality → client/modules/search.ts
49
+ │ └── Settings option → docs/configuration.md
50
+ ├── Fix a bug?
51
+ │ ├── UI issue → Check component + PubSub channels
52
+ │ ├── AI not working → docs/ai-integration.md + browser console
53
+ │ ├── Search failing → Check SearXNG + server hooks
54
+ │ └── Build error → docs/development-commands.md
55
+ ├── Configure deployment?
56
+ │ ├── Environment variables → docs/configuration.md
57
+ │ ├── Access control → docs/security.md
58
+ │ └── Docker setup → docs/overview.md
59
+ └── Understand data flow?
60
+ ├── Search flow → client/modules/search.ts
61
+ ├── AI generation → client/modules/textGeneration.ts
62
+ ├── State management → docs/ui-components.md
63
+ └── History/Chat → docs/search-history.md + docs/conversation-memory.md
64
+ ```
65
+
66
+ ## Key Files Reference
67
+
68
+ ### Entry Points
69
+ - `client/index.tsx` - React app initialization
70
+ - `vite.config.ts` - Vite dev server with hooks
71
+ - `Dockerfile` - Multi-stage container build
72
+
73
+ ### Business Logic Modules
74
+ - `client/modules/search.ts` - Search orchestration and caching
75
+ - `client/modules/textGeneration.ts` - AI response flow
76
+ - `client/modules/pubSub.ts` - All PubSub channels
77
+ - `client/modules/settings.ts` - Settings management
78
+ - `client/modules/history.ts` - Search history database
79
+
80
+ ### Server-Side Modules
81
+ - `server/searchEndpointServerHook.ts` - `/search` endpoints
82
+ - `server/internalApiEndpointServerHook.ts` - `/inference` proxy
83
+ - `server/webSearchService.ts` - SearXNG integration
84
+ - `server/rerankerService.ts` - Local result reranking
85
+
86
+ ### Key Components
87
+ - `client/components/App/` - Application shell
88
+ - `client/components/Search/Form/` - Search input
89
+ - `client/components/Search/Results/` - Results display
90
+ - `client/components/AiResponse/` - AI response + chat
91
+ - `client/components/Pages/Main/Menu/` - Settings drawers
92
+ - `client/modules/webGpu.ts` - Detects WebGPU availability and F16 shader support for WebLLM
93
+ - `client/modules/querySuggestions.ts` - Provides search suggestion UI, stored in IndexedDB
94
+ - `client/modules/relatedSearchQuery.ts` - Generates related search queries
95
+ - `client/modules/followUpQuestions.ts` - Generates suggested follow-up questions, uses `followUpQuestionPubSub`
96
+ - `client/modules/accessKey.ts` - Validates and stores access keys, uses `accessKeyValidatedPubSub`
97
+ - `client/modules/parentWindow.ts` - PostMessage API for embedding in parent windows
98
+ - `client/hooks/` - Reusable React hooks
99
+ - `server/searchToken.ts` - Generates CSRF protection tokens for search requests
100
+ - `server/downloadFileFromHuggingFaceRepository.ts` - Downloads GGUF models from HuggingFace using `@huggingface/hub` package
101
+
102
+ ## Common Tasks Quick Reference
103
+
104
+ ### Add a new AI model
105
+ 1. Add to `client/modules/wllama.ts` or WebLLM registry
106
+ 2. Update `docs/ai-integration.md`
107
+ 3. Update `docs/configuration.md` defaults
108
+
109
+ ### Add a new setting
110
+ 1. Add to `client/modules/settings.ts` default object
111
+ 2. Add UI in `client/components/Pages/Main/Menu/`
112
+ 3. Update `docs/configuration.md` settings table
113
+
114
+ ### Modify search behavior
115
+ 1. Edit `client/modules/search.ts`
116
+ 2. Update `server/webSearchService.ts` if server-side changes needed
117
+ 3. Check `server/rerankerService.ts` if reranking affected
118
+
119
+ ### Fix UI state issues
120
+ 1. Check PubSub channels in `client/modules/pubSub.ts`
121
+ 2. Verify component subscriptions in `docs/ui-components.md`
122
+ 3. Ensure proper state updates in business logic modules
123
+
124
+ ### Analyze test coverage
125
+ 1. Run `npm run test:coverage` to generate reports
126
+ 2. Check `coverage/coverage-summary.json` for quick metrics
127
+ 3. See `docs/development-commands.md` for full coverage analysis guide
128
+
129
+ ## Quality Gates
130
+
131
+ Before any change:
132
+ ```bash
133
+ docker compose exec development-server npm run lint
134
+ ```
135
+
136
+ This runs:
137
+ - Biome (formatting/linting)
138
+ - TypeScript (type checking)
139
+ - ts-prune (dead code detection)
140
+ - jscpd (copy-paste detection)
141
+ - dpdm (circular dependency detection)
142
+ - Custom architectural linter
143
+
144
+ ## Agent-First Principles
145
+
146
+ **Repository as System of Record:**
147
+ - All knowledge lives in versioned docs/ structure
148
+ - This file is your entry point - start here
149
+ - Follow links, don't assume - verify in code
150
+
151
+ **Context Efficiency:**
152
+ - Use this map to navigate quickly
153
+ - Return to this file when context drifts
154
+ - Follow the decision tree for common tasks
155
+
156
+ **Architecture & Boundaries:**
157
+ - Respect PubSub boundaries - don't cross concerns
158
+ - Client vs server - keep them separate
159
+ - Feature-based organization - one folder per feature
160
+
161
+ **Documentation Maintenance:**
162
+ - Update these docs when you learn something new
163
+ - Add cross-references when linking concepts
164
+ - Keep examples current with actual code
165
+
166
+ ## Technology Stack
167
+
168
+ React + TypeScript + Mantine UI v8, with privacy-first architecture.
169
+ See `docs/core-technologies.md` for complete dependency list and selection criteria.
170
+
171
+ ## Need Help?
172
+
173
+ 1. Check relevant doc in `docs/`
174
+ 2. Read the module code in `client/modules/` or `server/`
175
+ 3. Look at similar existing implementations
176
+ 4. Run `npm run lint` to validate changes
biome.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/latest/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "ignoreUnknown": false
10
+ },
11
+ "formatter": {
12
+ "enabled": true,
13
+ "indentStyle": "space"
14
+ },
15
+ "linter": {
16
+ "enabled": true,
17
+ "rules": {
18
+ "recommended": true
19
+ }
20
+ },
21
+ "javascript": {
22
+ "formatter": {
23
+ "quoteStyle": "double"
24
+ }
25
+ },
26
+ "assist": {
27
+ "enabled": true,
28
+ "actions": {
29
+ "source": {
30
+ "organizeImports": "on"
31
+ }
32
+ }
33
+ }
34
+ }
client/components/AiResponse/AiModelDownloadAllowanceContent.tsx ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Alert, Button, Group, Text } from "@mantine/core";
2
+ import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
3
+ import { usePubSub } from "create-pubsub/react";
4
+ import { useState } from "react";
5
+ import { addLogEntry } from "@/modules/logEntries";
6
+ import { settingsPubSub } from "@/modules/pubSub";
7
+
8
+ export default function AiModelDownloadAllowanceContent() {
9
+ const [settings, setSettings] = usePubSub(settingsPubSub);
10
+ const [hasDeniedDownload, setDeniedDownload] = useState(false);
11
+
12
+ const handleAccept = () => {
13
+ setSettings({
14
+ ...settings,
15
+ allowAiModelDownload: true,
16
+ });
17
+ addLogEntry("User allowed the AI model download");
18
+ };
19
+
20
+ const handleDecline = () => {
21
+ setDeniedDownload(true);
22
+ addLogEntry("User denied the AI model download");
23
+ };
24
+
25
+ return hasDeniedDownload ? null : (
26
+ <Alert
27
+ variant="light"
28
+ color="blue"
29
+ title="Allow AI model download?"
30
+ icon={<IconInfoCircle />}
31
+ >
32
+ <Text size="sm" mb="md">
33
+ To obtain AI responses, a language model needs to be downloaded to your
34
+ browser. Enabling this option lets the app store it and load it
35
+ instantly on subsequent uses.
36
+ </Text>
37
+ <Text size="sm" mb="md">
38
+ Please note that the download size ranges from 100 MB to 4 GB, depending
39
+ on the model you select in the Menu, so it's best to avoid using mobile
40
+ data for this.
41
+ </Text>
42
+ <Group justify="flex-end" mt="md">
43
+ <Button
44
+ variant="subtle"
45
+ color="gray"
46
+ leftSection={<IconX size="1rem" />}
47
+ onClick={handleDecline}
48
+ size="xs"
49
+ >
50
+ Not now
51
+ </Button>
52
+ <Button
53
+ leftSection={<IconCheck size="1rem" />}
54
+ onClick={handleAccept}
55
+ size="xs"
56
+ >
57
+ Allow download
58
+ </Button>
59
+ </Group>
60
+ </Alert>
61
+ );
62
+ }
client/components/AiResponse/AiResponseContent.tsx ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ ActionIcon,
3
+ Alert,
4
+ Badge,
5
+ Box,
6
+ Card,
7
+ Group,
8
+ ScrollArea,
9
+ Text,
10
+ Tooltip,
11
+ } from "@mantine/core";
12
+ import {
13
+ IconArrowsMaximize,
14
+ IconArrowsMinimize,
15
+ IconHandStop,
16
+ IconInfoCircle,
17
+ IconRefresh,
18
+ IconVolume2,
19
+ } from "@tabler/icons-react";
20
+ import type { PublishFunction } from "create-pubsub";
21
+ import { usePubSub } from "create-pubsub/react";
22
+ import { type ReactNode, useMemo, useState } from "react";
23
+ import { addLogEntry } from "@/modules/logEntries";
24
+ import { settingsPubSub } from "@/modules/pubSub";
25
+ import { searchAndRespond } from "@/modules/textGeneration";
26
+ import CopyIconButton from "./CopyIconButton";
27
+ import FormattedMarkdown from "./FormattedMarkdown";
28
+
29
+ export default function AiResponseContent({
30
+ textGenerationState,
31
+ response,
32
+ setTextGenerationState,
33
+ }: {
34
+ textGenerationState: string;
35
+ response: string;
36
+ setTextGenerationState: PublishFunction<
37
+ | "failed"
38
+ | "awaitingSearchResults"
39
+ | "preparingToGenerate"
40
+ | "idle"
41
+ | "loadingModel"
42
+ | "generating"
43
+ | "interrupted"
44
+ | "completed"
45
+ >;
46
+ }) {
47
+ const [settings, setSettings] = usePubSub(settingsPubSub);
48
+ const [isSpeaking, setIsSpeaking] = useState(false);
49
+
50
+ const ConditionalScrollArea = useMemo(
51
+ () =>
52
+ ({ children }: { children: ReactNode }) => {
53
+ return settings.enableAiResponseScrolling ? (
54
+ <ScrollArea.Autosize
55
+ mah={300}
56
+ type="auto"
57
+ scrollbars="y"
58
+ offsetScrollbars
59
+ >
60
+ {children}
61
+ </ScrollArea.Autosize>
62
+ ) : (
63
+ <Box>{children}</Box>
64
+ );
65
+ },
66
+ [settings.enableAiResponseScrolling],
67
+ );
68
+
69
+ function speakResponse(text: string) {
70
+ if (isSpeaking) {
71
+ self.speechSynthesis.cancel();
72
+ setIsSpeaking(false);
73
+ return;
74
+ }
75
+
76
+ const prepareTextForSpeech = (textToClean: string) => {
77
+ const withoutReasoning = textToClean.replace(
78
+ new RegExp(
79
+ `${settings.reasoningStartMarker}[\\s\\S]*?${settings.reasoningEndMarker}`,
80
+ "g",
81
+ ),
82
+ "",
83
+ );
84
+ const withoutLinks = withoutReasoning.replace(
85
+ /\[([^\]]+)\]\([^)]+\)/g,
86
+ "($1)",
87
+ );
88
+ const withoutMarkdown = withoutLinks.replace(/[#*`_~[\]]/g, "");
89
+ return withoutMarkdown.trim();
90
+ };
91
+
92
+ const utterance = new SpeechSynthesisUtterance(prepareTextForSpeech(text));
93
+
94
+ const voices = self.speechSynthesis.getVoices();
95
+
96
+ if (voices.length > 0 && settings.selectedVoiceId) {
97
+ const voice = voices.find(
98
+ (voice) => voice.voiceURI === settings.selectedVoiceId,
99
+ );
100
+
101
+ if (voice) {
102
+ utterance.voice = voice;
103
+ utterance.lang = voice.lang;
104
+ }
105
+ }
106
+
107
+ utterance.onerror = () => {
108
+ addLogEntry("Failed to speak response");
109
+ setIsSpeaking(false);
110
+ };
111
+
112
+ utterance.onend = () => setIsSpeaking(false);
113
+
114
+ setIsSpeaking(true);
115
+ self.speechSynthesis.speak(utterance);
116
+ }
117
+
118
+ return (
119
+ <Card withBorder shadow="sm" radius="md">
120
+ <Card.Section withBorder inheritPadding py="xs">
121
+ <Group justify="space-between">
122
+ <Group gap="xs" align="center">
123
+ <Text fw={500}>
124
+ {textGenerationState === "generating"
125
+ ? "Generating AI Response..."
126
+ : "AI Response"}
127
+ </Text>
128
+ {textGenerationState === "interrupted" && (
129
+ <Badge variant="light" color="yellow" size="xs">
130
+ Interrupted
131
+ </Badge>
132
+ )}
133
+ </Group>
134
+ <Group gap="xs" align="center">
135
+ {textGenerationState === "generating" ? (
136
+ <Tooltip label="Interrupt generation">
137
+ <ActionIcon
138
+ onClick={() => setTextGenerationState("interrupted")}
139
+ variant="subtle"
140
+ color="gray"
141
+ >
142
+ <IconHandStop size={16} />
143
+ </ActionIcon>
144
+ </Tooltip>
145
+ ) : (
146
+ <Tooltip label="Regenerate response">
147
+ <ActionIcon
148
+ onClick={() => searchAndRespond()}
149
+ variant="subtle"
150
+ color="gray"
151
+ >
152
+ <IconRefresh size={16} />
153
+ </ActionIcon>
154
+ </Tooltip>
155
+ )}
156
+ <Tooltip
157
+ label={isSpeaking ? "Stop speaking" : "Listen to response"}
158
+ >
159
+ <ActionIcon
160
+ onClick={() => speakResponse(response)}
161
+ variant="subtle"
162
+ color={isSpeaking ? "blue" : "gray"}
163
+ >
164
+ <IconVolume2 size={16} />
165
+ </ActionIcon>
166
+ </Tooltip>
167
+ {settings.enableAiResponseScrolling ? (
168
+ <Tooltip label="Show full response without scroll bar">
169
+ <ActionIcon
170
+ onClick={() => {
171
+ setSettings({
172
+ ...settings,
173
+ enableAiResponseScrolling: false,
174
+ });
175
+ }}
176
+ variant="subtle"
177
+ color="gray"
178
+ >
179
+ <IconArrowsMaximize size={16} />
180
+ </ActionIcon>
181
+ </Tooltip>
182
+ ) : (
183
+ <Tooltip label="Enable scroll bar">
184
+ <ActionIcon
185
+ onClick={() => {
186
+ setSettings({
187
+ ...settings,
188
+ enableAiResponseScrolling: true,
189
+ });
190
+ }}
191
+ variant="subtle"
192
+ color="gray"
193
+ >
194
+ <IconArrowsMinimize size={16} />
195
+ </ActionIcon>
196
+ </Tooltip>
197
+ )}
198
+ <CopyIconButton value={response} tooltipLabel="Copy response" />
199
+ </Group>
200
+ </Group>
201
+ </Card.Section>
202
+ <Card.Section withBorder>
203
+ <ConditionalScrollArea>
204
+ <FormattedMarkdown>{response}</FormattedMarkdown>
205
+ </ConditionalScrollArea>
206
+ {textGenerationState === "failed" && (
207
+ <Alert
208
+ variant="light"
209
+ color="yellow"
210
+ title="Failed to generate response"
211
+ icon={<IconInfoCircle />}
212
+ >
213
+ Could not generate response. Please try refreshing the page.
214
+ </Alert>
215
+ )}
216
+ </Card.Section>
217
+ </Card>
218
+ );
219
+ }
client/components/AiResponse/AiResponseSection.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CodeHighlightAdapterProvider } from "@mantine/code-highlight";
2
+ import { usePubSub } from "create-pubsub/react";
3
+ import { useMemo } from "react";
4
+ import {
5
+ chatMessagesPubSub,
6
+ isRestoringFromHistoryPubSub,
7
+ modelLoadingProgressPubSub,
8
+ modelSizeInMegabytesPubSub,
9
+ queryPubSub,
10
+ responsePubSub,
11
+ settingsPubSub,
12
+ textGenerationStatePubSub,
13
+ } from "@/modules/pubSub";
14
+ import { shikiAdapter } from "@/modules/shiki";
15
+ import "@mantine/code-highlight/styles.css";
16
+ import AiModelDownloadAllowanceContent from "./AiModelDownloadAllowanceContent";
17
+ import AiResponseContent from "./AiResponseContent";
18
+ import ChatInterface from "./ChatInterface";
19
+ import LoadingModelContent from "./LoadingModelContent";
20
+ import PreparingContent from "./PreparingContent";
21
+
22
+ export default function AiResponseSection() {
23
+ const [query] = usePubSub(queryPubSub);
24
+ const [response] = usePubSub(responsePubSub);
25
+ const [textGenerationState, setTextGenerationState] = usePubSub(
26
+ textGenerationStatePubSub,
27
+ );
28
+ const [modelLoadingProgress] = usePubSub(modelLoadingProgressPubSub);
29
+ const [settings] = usePubSub(settingsPubSub);
30
+ const [modelSizeInMegabytes] = usePubSub(modelSizeInMegabytesPubSub);
31
+ const [chatMessages] = usePubSub(chatMessagesPubSub);
32
+ const [isRestoringFromHistory] = usePubSub(isRestoringFromHistoryPubSub);
33
+
34
+ return useMemo(() => {
35
+ if (!settings.enableAiResponse || textGenerationState === "idle") {
36
+ return null;
37
+ }
38
+
39
+ const generatingStates = [
40
+ "generating",
41
+ "interrupted",
42
+ "completed",
43
+ "failed",
44
+ ];
45
+ if (generatingStates.includes(textGenerationState)) {
46
+ return (
47
+ <CodeHighlightAdapterProvider adapter={shikiAdapter}>
48
+ <AiResponseContent
49
+ textGenerationState={textGenerationState}
50
+ response={response}
51
+ setTextGenerationState={setTextGenerationState}
52
+ />
53
+
54
+ {textGenerationState === "completed" && (
55
+ <ChatInterface
56
+ initialQuery={query}
57
+ initialResponse={response}
58
+ initialMessages={
59
+ chatMessages.length > 0 ? chatMessages : undefined
60
+ }
61
+ suppressInitialFollowUp={isRestoringFromHistory}
62
+ />
63
+ )}
64
+ </CodeHighlightAdapterProvider>
65
+ );
66
+ }
67
+
68
+ if (textGenerationState === "loadingModel") {
69
+ return (
70
+ <LoadingModelContent
71
+ modelLoadingProgress={modelLoadingProgress}
72
+ modelSizeInMegabytes={modelSizeInMegabytes}
73
+ />
74
+ );
75
+ }
76
+
77
+ if (textGenerationState === "preparingToGenerate") {
78
+ return <PreparingContent textGenerationState={textGenerationState} />;
79
+ }
80
+
81
+ if (textGenerationState === "awaitingSearchResults") {
82
+ return <PreparingContent textGenerationState={textGenerationState} />;
83
+ }
84
+
85
+ if (textGenerationState === "awaitingModelDownloadAllowance") {
86
+ return <AiModelDownloadAllowanceContent />;
87
+ }
88
+
89
+ return null;
90
+ }, [
91
+ settings.enableAiResponse,
92
+ textGenerationState,
93
+ response,
94
+ query,
95
+ chatMessages,
96
+ modelLoadingProgress,
97
+ modelSizeInMegabytes,
98
+ setTextGenerationState,
99
+ isRestoringFromHistory,
100
+ ]);
101
+ }
client/components/AiResponse/ChatHeader.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Group, Text } from "@mantine/core";
2
+ import type { ChatMessage } from "@/modules/types";
3
+ import CopyIconButton from "./CopyIconButton";
4
+
5
+ interface ChatHeaderProps {
6
+ messages: ChatMessage[];
7
+ }
8
+
9
+ function ChatHeader({ messages }: ChatHeaderProps) {
10
+ const getChatContent = () => {
11
+ return messages
12
+ .slice(2)
13
+ .map(
14
+ (msg, index) =>
15
+ `${index + 1}. ${msg.role?.toUpperCase()}\n\n${msg.content}`,
16
+ )
17
+ .join("\n\n");
18
+ };
19
+
20
+ return (
21
+ <Group justify="space-between">
22
+ <Text fw={500}>Follow-up questions</Text>
23
+ {messages.length > 2 && (
24
+ <CopyIconButton
25
+ value={getChatContent()}
26
+ tooltipLabel="Copy conversation"
27
+ />
28
+ )}
29
+ </Group>
30
+ );
31
+ }
32
+
33
+ export default ChatHeader;
client/components/AiResponse/ChatInputArea.tsx ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button, Group, Textarea } from "@mantine/core";
2
+ import { IconSend } from "@tabler/icons-react";
3
+ import { usePubSub } from "create-pubsub/react";
4
+ import {
5
+ chatGenerationStatePubSub,
6
+ chatInputPubSub,
7
+ followUpQuestionPubSub,
8
+ isRestoringFromHistoryPubSub,
9
+ suppressNextFollowUpPubSub,
10
+ } from "@/modules/pubSub";
11
+
12
+ interface ChatInputAreaProps {
13
+ onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
14
+ handleSend: (textToSend?: string) => void;
15
+ }
16
+
17
+ function ChatInputArea({ onKeyDown, handleSend }: ChatInputAreaProps) {
18
+ const [input, setInput] = usePubSub(chatInputPubSub);
19
+ const [generationState] = usePubSub(chatGenerationStatePubSub);
20
+ const [followUpQuestion] = usePubSub(followUpQuestionPubSub);
21
+ const [isRestoringFromHistory] = usePubSub(isRestoringFromHistoryPubSub);
22
+ const [suppressNextFollowUp] = usePubSub(suppressNextFollowUpPubSub);
23
+
24
+ const isGenerating =
25
+ generationState.isGeneratingResponse &&
26
+ !generationState.isGeneratingFollowUpQuestion;
27
+
28
+ const defaultPlaceholder = "Anything else you would like to know?";
29
+ const placeholder =
30
+ isRestoringFromHistory || suppressNextFollowUp
31
+ ? defaultPlaceholder
32
+ : followUpQuestion || defaultPlaceholder;
33
+
34
+ const onChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
35
+ setInput(event.target.value);
36
+ };
37
+ const handleKeyDownWithPlaceholder = (
38
+ event: React.KeyboardEvent<HTMLTextAreaElement>,
39
+ ) => {
40
+ if (
41
+ input.trim() === "" &&
42
+ followUpQuestion &&
43
+ !isRestoringFromHistory &&
44
+ !suppressNextFollowUp
45
+ ) {
46
+ if (event.key === "Enter" && !event.shiftKey) {
47
+ event.preventDefault();
48
+ handleSend(followUpQuestion);
49
+ return;
50
+ }
51
+ }
52
+
53
+ onKeyDown(event);
54
+ };
55
+
56
+ const handleSendWithPlaceholder = () => {
57
+ if (
58
+ input.trim() === "" &&
59
+ followUpQuestion &&
60
+ !isRestoringFromHistory &&
61
+ !suppressNextFollowUp
62
+ ) {
63
+ handleSend(followUpQuestion);
64
+ } else {
65
+ handleSend();
66
+ }
67
+ };
68
+
69
+ return (
70
+ <Group align="flex-end" style={{ position: "relative" }}>
71
+ <Textarea
72
+ size="sm"
73
+ aria-label="Chat input"
74
+ placeholder={placeholder}
75
+ value={input}
76
+ onChange={onChange}
77
+ onKeyDown={handleKeyDownWithPlaceholder}
78
+ autosize
79
+ minRows={1}
80
+ maxRows={8}
81
+ style={{ flexGrow: 1, paddingRight: "50px" }}
82
+ disabled={isGenerating}
83
+ />
84
+ <Button
85
+ aria-label="Send message"
86
+ size="sm"
87
+ variant="default"
88
+ onClick={handleSendWithPlaceholder}
89
+ loading={isGenerating}
90
+ style={{
91
+ height: "100%",
92
+ position: "absolute",
93
+ right: 0,
94
+ top: 0,
95
+ bottom: 0,
96
+ borderTopLeftRadius: 0,
97
+ borderBottomLeftRadius: 0,
98
+ }}
99
+ >
100
+ <IconSend size={16} />
101
+ </Button>
102
+ </Group>
103
+ );
104
+ }
105
+
106
+ export default ChatInputArea;
client/components/AiResponse/ChatInterface.tsx ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Card, Stack } from "@mantine/core";
2
+ import { usePubSub } from "create-pubsub/react";
3
+ import {
4
+ type KeyboardEvent,
5
+ useCallback,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import throttle from "throttleit";
11
+ import { generateFollowUpQuestion } from "@/modules/followUpQuestions";
12
+ import {
13
+ getCurrentSearchRunId,
14
+ saveChatMessageForQuery,
15
+ updateSearchResults,
16
+ } from "@/modules/history";
17
+ import { handleEnterKeyDown } from "@/modules/keyboard";
18
+ import { addLogEntry } from "@/modules/logEntries";
19
+ import {
20
+ chatGenerationStatePubSub,
21
+ chatInputPubSub,
22
+ followUpQuestionPubSub,
23
+ getSettings,
24
+ imageSearchResultsPubSub,
25
+ queryPubSub,
26
+ settingsPubSub,
27
+ suppressNextFollowUpPubSub,
28
+ textSearchResultsPubSub,
29
+ updateImageSearchResults,
30
+ updateLlmTextSearchResults,
31
+ updateTextSearchResults,
32
+ } from "@/modules/pubSub";
33
+ import { generateRelatedSearchQuery } from "@/modules/relatedSearchQuery";
34
+ import { searchImages, searchText } from "@/modules/search";
35
+ import { generateChatResponse } from "@/modules/textGeneration";
36
+ import type { ChatMessage } from "@/modules/types";
37
+ import ChatHeader from "./ChatHeader";
38
+ import ChatInputArea from "./ChatInputArea";
39
+ import MessageList from "./MessageList";
40
+
41
+ interface ChatInterfaceProps {
42
+ initialQuery?: string;
43
+ initialResponse?: string;
44
+ initialMessages?: ChatMessage[];
45
+ suppressInitialFollowUp?: boolean;
46
+ }
47
+
48
+ export default function ChatInterface({
49
+ initialQuery,
50
+ initialResponse,
51
+ initialMessages,
52
+ suppressInitialFollowUp,
53
+ }: ChatInterfaceProps) {
54
+ const initialMessagesArray =
55
+ initialMessages &&
56
+ initialMessages.length > 0 &&
57
+ initialQuery &&
58
+ initialResponse
59
+ ? [
60
+ { role: "user" as const, content: initialQuery },
61
+ { role: "assistant" as const, content: initialResponse },
62
+ ...initialMessages,
63
+ ]
64
+ : initialMessages || [];
65
+
66
+ const [messages, setMessages] = useState<ChatMessage[]>(initialMessagesArray);
67
+ const [input, setInput] = usePubSub(chatInputPubSub);
68
+ const [generationState, setGenerationState] = usePubSub(
69
+ chatGenerationStatePubSub,
70
+ );
71
+ const [, setFollowUpQuestion] = usePubSub(followUpQuestionPubSub);
72
+ const [textSearchResults] = usePubSub(textSearchResultsPubSub);
73
+ const [imageSearchResults] = usePubSub(imageSearchResultsPubSub);
74
+ const [currentQuery] = usePubSub(queryPubSub);
75
+ const [suppressNextFollowUp] = usePubSub(suppressNextFollowUpPubSub);
76
+ const [previousFollowUpQuestions, setPreviousFollowUpQuestions] = useState<
77
+ string[]
78
+ >([]);
79
+ const [settings] = usePubSub(settingsPubSub);
80
+ const [streamedResponse, setStreamedResponse] = useState("");
81
+ const hasInitialized = useRef(false);
82
+ const prevInitialMessagesRef = useRef<ChatMessage[] | undefined>(undefined);
83
+ const updateStreamedResponse = useCallback(
84
+ throttle((response: string) => {
85
+ setStreamedResponse(response);
86
+ }, 1000 / 12),
87
+ [],
88
+ );
89
+
90
+ const regenerateFollowUpQuestion = useCallback(
91
+ async (currentQuery: string, currentResponse: string) => {
92
+ if (suppressNextFollowUp) return;
93
+ if (!currentResponse || !currentQuery.trim()) return;
94
+
95
+ try {
96
+ setGenerationState({
97
+ isGeneratingResponse: false,
98
+ isGeneratingFollowUpQuestion: true,
99
+ });
100
+
101
+ const newQuestion = await generateFollowUpQuestion({
102
+ topic: currentQuery,
103
+ currentContent: currentResponse,
104
+ previousQuestions: previousFollowUpQuestions,
105
+ });
106
+
107
+ setPreviousFollowUpQuestions((prev) =>
108
+ [...prev, newQuestion].slice(-5),
109
+ );
110
+ setFollowUpQuestion(newQuestion);
111
+ setGenerationState({
112
+ isGeneratingResponse: false,
113
+ isGeneratingFollowUpQuestion: false,
114
+ });
115
+ } catch (_) {
116
+ setFollowUpQuestion("");
117
+ setGenerationState({
118
+ isGeneratingResponse: false,
119
+ isGeneratingFollowUpQuestion: false,
120
+ });
121
+ }
122
+ },
123
+ [
124
+ setFollowUpQuestion,
125
+ setGenerationState,
126
+ previousFollowUpQuestions,
127
+ suppressNextFollowUp,
128
+ ],
129
+ );
130
+
131
+ useEffect(() => {
132
+ const messagesChanged =
133
+ !prevInitialMessagesRef.current ||
134
+ JSON.stringify(prevInitialMessagesRef.current) !==
135
+ JSON.stringify(initialMessages);
136
+
137
+ if (!messagesChanged) return;
138
+
139
+ prevInitialMessagesRef.current = initialMessages;
140
+
141
+ const newInitialMessagesArray =
142
+ initialMessages &&
143
+ initialMessages.length > 0 &&
144
+ initialQuery &&
145
+ initialResponse
146
+ ? [
147
+ { role: "user" as const, content: initialQuery },
148
+ { role: "assistant" as const, content: initialResponse },
149
+ ...initialMessages,
150
+ ]
151
+ : initialMessages || [];
152
+
153
+ if (newInitialMessagesArray.length > 0) {
154
+ setMessages(newInitialMessagesArray);
155
+ } else if (initialQuery && initialResponse) {
156
+ setMessages([
157
+ { role: "user", content: initialQuery },
158
+ { role: "assistant", content: initialResponse },
159
+ ]);
160
+ }
161
+ }, [initialQuery, initialResponse, initialMessages]);
162
+
163
+ useEffect(() => {
164
+ if (suppressNextFollowUp) {
165
+ hasInitialized.current = true;
166
+ return;
167
+ }
168
+ if (suppressInitialFollowUp) return;
169
+ if (hasInitialized.current) return;
170
+
171
+ if (initialMessages && initialMessages.length > 0) {
172
+ const lastAssistant = messages
173
+ .filter((m) => m.role === "assistant")
174
+ .pop();
175
+ const lastUser = messages.filter((m) => m.role === "user").pop();
176
+ if (lastUser && lastAssistant) {
177
+ regenerateFollowUpQuestion(lastUser.content, lastAssistant.content);
178
+ hasInitialized.current = true;
179
+ }
180
+ } else if (messages.length >= 2 && initialQuery && initialResponse) {
181
+ regenerateFollowUpQuestion(initialQuery, initialResponse);
182
+ hasInitialized.current = true;
183
+ }
184
+ }, [
185
+ initialQuery,
186
+ initialResponse,
187
+ initialMessages,
188
+ messages,
189
+ regenerateFollowUpQuestion,
190
+ suppressInitialFollowUp,
191
+ suppressNextFollowUp,
192
+ ]);
193
+
194
+ useEffect(() => {
195
+ return () => {
196
+ setFollowUpQuestion("");
197
+ setPreviousFollowUpQuestions([]);
198
+ };
199
+ }, [setFollowUpQuestion]);
200
+
201
+ const handleEditMessage = useCallback(
202
+ (absoluteIndex: number) => {
203
+ const target = messages[absoluteIndex];
204
+ if (!target || target.role !== "user") return;
205
+ setInput(target.content);
206
+ setMessages(messages.slice(0, absoluteIndex));
207
+ setFollowUpQuestion("");
208
+ },
209
+ [messages, setInput, setFollowUpQuestion],
210
+ );
211
+
212
+ const handleRegenerateResponse = useCallback(async () => {
213
+ if (
214
+ generationState.isGeneratingResponse ||
215
+ messages.length < 3 ||
216
+ messages[messages.length - 1].role !== "assistant"
217
+ )
218
+ return;
219
+
220
+ const history = messages.slice(0, -1);
221
+ const lastUser = history[history.length - 1];
222
+
223
+ setMessages(history);
224
+ setGenerationState({ ...generationState, isGeneratingResponse: true });
225
+ setFollowUpQuestion("");
226
+ setStreamedResponse("");
227
+
228
+ try {
229
+ const finalResponse = await generateChatResponse(
230
+ history,
231
+ updateStreamedResponse,
232
+ );
233
+
234
+ setMessages((prev) => [
235
+ ...prev,
236
+ { role: "assistant", content: finalResponse },
237
+ ]);
238
+
239
+ addLogEntry("AI response re-generated");
240
+
241
+ if (lastUser?.role === "user") {
242
+ await regenerateFollowUpQuestion(lastUser.content, finalResponse);
243
+ }
244
+ } catch (error) {
245
+ addLogEntry(`Error re-generating response: ${error}`);
246
+ } finally {
247
+ setGenerationState({ ...generationState, isGeneratingResponse: false });
248
+ }
249
+ }, [
250
+ generationState,
251
+ messages,
252
+ regenerateFollowUpQuestion,
253
+ setFollowUpQuestion,
254
+ setGenerationState,
255
+ updateStreamedResponse,
256
+ ]);
257
+
258
+ const handleSend = useCallback(
259
+ async (textToSend?: string) => {
260
+ const currentInput = textToSend ?? input;
261
+ if (currentInput.trim() === "" || generationState.isGeneratingResponse)
262
+ return;
263
+
264
+ const userMessage: ChatMessage = { role: "user", content: currentInput };
265
+ const newMessages: ChatMessage[] = [...messages, userMessage];
266
+
267
+ setMessages(newMessages);
268
+ if (!textToSend) setInput("");
269
+ setGenerationState({
270
+ ...generationState,
271
+ isGeneratingResponse: true,
272
+ });
273
+ setFollowUpQuestion("");
274
+ setStreamedResponse("");
275
+
276
+ try {
277
+ const relatedQuery = await generateRelatedSearchQuery([...newMessages]);
278
+ const searchQuery = relatedQuery || currentInput;
279
+
280
+ if (settings.enableTextSearch) {
281
+ const freshResults = await searchText(
282
+ searchQuery,
283
+ settings.searchResultsLimit,
284
+ );
285
+
286
+ if (freshResults.length > 0) {
287
+ const existingUrls = new Set(
288
+ textSearchResults.map(([, , url]) => url),
289
+ );
290
+
291
+ const uniqueFreshResults = freshResults.filter(
292
+ ([, , url]) => !existingUrls.has(url),
293
+ );
294
+
295
+ updateLlmTextSearchResults(
296
+ freshResults.slice(0, getSettings().searchResultsToConsider),
297
+ );
298
+
299
+ if (uniqueFreshResults.length > 0) {
300
+ const updatedResults = [
301
+ ...textSearchResults,
302
+ ...uniqueFreshResults,
303
+ ];
304
+ updateTextSearchResults(updatedResults);
305
+
306
+ updateSearchResults(getCurrentSearchRunId(), {
307
+ type: "text",
308
+ items: updatedResults.map(([title, snippet, url]) => ({
309
+ title,
310
+ url,
311
+ snippet,
312
+ })),
313
+ });
314
+ }
315
+ }
316
+ }
317
+
318
+ if (settings.enableImageSearch) {
319
+ searchImages(searchQuery, settings.searchResultsLimit)
320
+ .then((imageResults) => {
321
+ if (imageResults.length > 0) {
322
+ const existingUrls = new Set(
323
+ imageSearchResults.map(([, url]) => url),
324
+ );
325
+
326
+ const uniqueFreshResults = imageResults.filter(
327
+ ([, url]) => !existingUrls.has(url),
328
+ );
329
+
330
+ if (uniqueFreshResults.length > 0) {
331
+ const updatedImageResults = [
332
+ ...uniqueFreshResults,
333
+ ...imageSearchResults,
334
+ ];
335
+ updateImageSearchResults(updatedImageResults);
336
+
337
+ updateSearchResults(getCurrentSearchRunId(), {
338
+ type: "image",
339
+ items: updatedImageResults.map(
340
+ ([title, url, thumbnailUrl, sourceUrl]) => ({
341
+ title,
342
+ url,
343
+ thumbnail: thumbnailUrl,
344
+ sourceUrl,
345
+ }),
346
+ ),
347
+ });
348
+ }
349
+ }
350
+ })
351
+ .catch((error) => {
352
+ addLogEntry(`Error in follow-up image search: ${error}`);
353
+ });
354
+ }
355
+ } catch (error) {
356
+ addLogEntry(`Error in follow-up search: ${error}`);
357
+ }
358
+
359
+ try {
360
+ const finalResponse = await generateChatResponse(
361
+ newMessages,
362
+ updateStreamedResponse,
363
+ );
364
+
365
+ setMessages((prevMessages) => [
366
+ ...prevMessages,
367
+ { role: "assistant", content: finalResponse },
368
+ ]);
369
+
370
+ addLogEntry("AI response completed");
371
+
372
+ await saveChatMessageForQuery(currentQuery, "user", currentInput);
373
+ await saveChatMessageForQuery(currentQuery, "assistant", finalResponse);
374
+
375
+ await regenerateFollowUpQuestion(currentInput, finalResponse);
376
+ } catch (error) {
377
+ addLogEntry(`Error in chat response: ${error}`);
378
+ setMessages((prevMessages) => [
379
+ ...prevMessages,
380
+ {
381
+ role: "assistant",
382
+ content:
383
+ "Sorry, I encountered an error while generating a response.",
384
+ },
385
+ ]);
386
+ } finally {
387
+ setGenerationState({
388
+ ...generationState,
389
+ isGeneratingResponse: false,
390
+ });
391
+ }
392
+ },
393
+ [
394
+ generationState,
395
+ messages,
396
+ settings,
397
+ input,
398
+ regenerateFollowUpQuestion,
399
+ setFollowUpQuestion,
400
+ setGenerationState,
401
+ setInput,
402
+ updateStreamedResponse,
403
+ currentQuery,
404
+ textSearchResults,
405
+ imageSearchResults,
406
+ ],
407
+ );
408
+
409
+ const handleKeyDown = useCallback(
410
+ (event: KeyboardEvent<HTMLTextAreaElement>) => {
411
+ handleEnterKeyDown(event, settings, handleSend);
412
+ },
413
+ [settings, handleSend],
414
+ );
415
+
416
+ return (
417
+ <Card withBorder shadow="sm" radius="md">
418
+ <Card.Section withBorder inheritPadding py="xs">
419
+ <ChatHeader messages={messages} />
420
+ </Card.Section>
421
+ <Stack gap="md" pt="md">
422
+ <MessageList
423
+ messages={
424
+ generationState.isGeneratingResponse
425
+ ? [...messages, { role: "assistant", content: streamedResponse }]
426
+ : messages
427
+ }
428
+ onEditMessage={handleEditMessage}
429
+ onRegenerate={handleRegenerateResponse}
430
+ isGenerating={generationState.isGeneratingResponse}
431
+ />
432
+ <ChatInputArea onKeyDown={handleKeyDown} handleSend={handleSend} />
433
+ </Stack>
434
+ </Card>
435
+ );
436
+ }
client/components/AiResponse/CopyIconButton.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
2
+ import { IconCheck, IconCopy } from "@tabler/icons-react";
3
+
4
+ interface CopyIconButtonProps {
5
+ value: string;
6
+ tooltipLabel?: string;
7
+ }
8
+
9
+ export default function CopyIconButton({
10
+ value,
11
+ tooltipLabel = "Copy",
12
+ }: CopyIconButtonProps) {
13
+ return (
14
+ <CopyButton value={value} timeout={2000}>
15
+ {({ copied, copy }) => (
16
+ <Tooltip
17
+ label={copied ? "Copied" : tooltipLabel}
18
+ withArrow
19
+ position="right"
20
+ >
21
+ <ActionIcon
22
+ color={copied ? "teal" : "gray"}
23
+ variant="subtle"
24
+ onClick={copy}
25
+ >
26
+ {copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
27
+ </ActionIcon>
28
+ </Tooltip>
29
+ )}
30
+ </CopyButton>
31
+ );
32
+ }
client/components/AiResponse/EnableAiResponsePrompt.tsx ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ ActionIcon,
3
+ Alert,
4
+ Button,
5
+ Grid,
6
+ Group,
7
+ Popover,
8
+ Stack,
9
+ Text,
10
+ } from "@mantine/core";
11
+ import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
12
+
13
+ interface EnableAiResponsePromptProps {
14
+ onAccept: () => void;
15
+ onDecline: () => void;
16
+ }
17
+
18
+ export default function EnableAiResponsePrompt({
19
+ onAccept,
20
+ onDecline,
21
+ }: EnableAiResponsePromptProps) {
22
+ const helpContent = (
23
+ <Stack gap="xs" p="xs">
24
+ <Text size="sm">
25
+ MiniSearch is a web-searching app with an integrated AI assistant.
26
+ </Text>
27
+ <Text size="sm">
28
+ With AI Responses enabled, it will generate summaries and answer
29
+ questions based on search results.
30
+ </Text>
31
+ <Text size="sm">
32
+ If disabled, it will function as a classic web search tool.
33
+ </Text>
34
+ <Text size="sm" c="dimmed" component="em">
35
+ You can toggle this feature at anytime through the Menu.
36
+ </Text>
37
+ </Stack>
38
+ );
39
+
40
+ return (
41
+ <Alert variant="light" color="blue" p="xs">
42
+ <Grid justify="space-between" align="center">
43
+ <Grid.Col span="content">
44
+ <Group gap="xs">
45
+ <Text fw={500}>Enable AI Responses?</Text>
46
+ <Popover
47
+ width={300}
48
+ styles={{ dropdown: { maxWidth: "92vw" } }}
49
+ position="bottom"
50
+ withArrow
51
+ shadow="md"
52
+ >
53
+ <Popover.Target>
54
+ <ActionIcon variant="subtle" color="blue" size="sm">
55
+ <IconInfoCircle size="1rem" />
56
+ </ActionIcon>
57
+ </Popover.Target>
58
+ <Popover.Dropdown>{helpContent}</Popover.Dropdown>
59
+ </Popover>
60
+ </Group>
61
+ </Grid.Col>
62
+ <Grid.Col span="auto">
63
+ <Group justify="end">
64
+ <Button
65
+ variant="subtle"
66
+ color="gray"
67
+ leftSection={<IconX size="1rem" />}
68
+ onClick={onDecline}
69
+ size="xs"
70
+ >
71
+ No, thanks
72
+ </Button>
73
+ <Button
74
+ leftSection={<IconCheck size="1rem" />}
75
+ onClick={onAccept}
76
+ size="xs"
77
+ >
78
+ Yes, please
79
+ </Button>
80
+ </Group>
81
+ </Grid.Col>
82
+ </Grid>
83
+ </Alert>
84
+ );
85
+ }
client/components/AiResponse/ExpandableLink.tsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MantineTheme } from "@mantine/core";
2
+ import { Button } from "@mantine/core";
3
+ import React from "react";
4
+
5
+ interface ExpandableLinkProps {
6
+ href: string;
7
+ children: React.ReactNode;
8
+ }
9
+
10
+ export default function ExpandableLink({
11
+ href,
12
+ children,
13
+ }: ExpandableLinkProps) {
14
+ const childContent = children?.toString() || "";
15
+ const firstChar = childContent.charAt(0);
16
+ const [isExpanded, setIsExpanded] = React.useState(true);
17
+ const timerRef = React.useRef<number | null>(null);
18
+
19
+ React.useEffect(() => {
20
+ timerRef.current = window.setTimeout(() => {
21
+ setIsExpanded(false);
22
+ timerRef.current = null;
23
+ }, 3000);
24
+
25
+ return () => {
26
+ if (timerRef.current) {
27
+ clearTimeout(timerRef.current);
28
+ }
29
+ };
30
+ }, []);
31
+
32
+ const handleMouseEnter = () => {
33
+ if (timerRef.current) {
34
+ clearTimeout(timerRef.current);
35
+ timerRef.current = null;
36
+ }
37
+ setIsExpanded(true);
38
+ };
39
+
40
+ const handleMouseLeave = () => {
41
+ timerRef.current = window.setTimeout(() => {
42
+ setIsExpanded(false);
43
+ timerRef.current = null;
44
+ }, 3000);
45
+ };
46
+
47
+ const fullTextRef = React.useRef<HTMLDivElement>(null);
48
+ const [fullTextWidth, setFullTextWidth] = React.useState(0);
49
+
50
+ React.useEffect(() => {
51
+ const measureText = () => {
52
+ if (fullTextRef.current) {
53
+ setFullTextWidth(fullTextRef.current.scrollWidth);
54
+ }
55
+ };
56
+
57
+ measureText();
58
+
59
+ window.addEventListener("resize", measureText);
60
+ return () => {
61
+ window.removeEventListener("resize", measureText);
62
+ };
63
+ }, []);
64
+
65
+ return (
66
+ <Button
67
+ component="a"
68
+ href={href}
69
+ target="_blank"
70
+ rel="nofollow noopener noreferrer"
71
+ variant="light"
72
+ color="gray"
73
+ size="compact-xs"
74
+ radius="xl"
75
+ style={(theme: MantineTheme) => ({
76
+ textDecoration: "none",
77
+ transform: "translateY(-2px)",
78
+ overflow: "hidden",
79
+ position: "relative",
80
+ width: isExpanded ? `${fullTextWidth + theme.spacing.md}px` : "2em",
81
+ transition: "width 0.3s ease-in-out",
82
+ textAlign: "center",
83
+ })}
84
+ onMouseEnter={handleMouseEnter}
85
+ onMouseLeave={handleMouseLeave}
86
+ onFocus={handleMouseEnter}
87
+ onBlur={handleMouseLeave}
88
+ >
89
+ <span
90
+ style={{
91
+ position: "absolute",
92
+ top: 0,
93
+ left: 0,
94
+ right: 0,
95
+ bottom: 0,
96
+ display: "flex",
97
+ alignItems: "center",
98
+ justifyContent: "center",
99
+ opacity: isExpanded ? 0 : 1,
100
+ transition: "opacity 0.2s ease-in-out",
101
+ }}
102
+ >
103
+ {firstChar}
104
+ </span>
105
+ <span
106
+ ref={fullTextRef}
107
+ style={{
108
+ opacity: isExpanded ? 1 : 0,
109
+ transition: "opacity 0.3s ease-in-out",
110
+ visibility: isExpanded ? "visible" : "hidden",
111
+ whiteSpace: "nowrap",
112
+ display: "flex",
113
+ alignItems: "center",
114
+ justifyContent: "center",
115
+ height: "100%",
116
+ position: "relative",
117
+ }}
118
+ >
119
+ {children}
120
+ </span>
121
+ </Button>
122
+ );
123
+ }
client/components/AiResponse/FormattedMarkdown.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { TypographyStylesProvider } from "@mantine/core";
2
+ import { useReasoningContent } from "./hooks/useReasoningContent";
3
+ import MarkdownRenderer from "./MarkdownRenderer";
4
+ import ReasoningSection from "./ReasoningSection";
5
+
6
+ interface FormattedMarkdownProps {
7
+ children: string;
8
+ className?: string;
9
+ enableCopy?: boolean;
10
+ }
11
+
12
+ export default function FormattedMarkdown({
13
+ children,
14
+ className = "",
15
+ enableCopy = true,
16
+ }: FormattedMarkdownProps) {
17
+ const { reasoningContent, mainContent, isGenerating } =
18
+ useReasoningContent(children);
19
+
20
+ if (!children && !reasoningContent) {
21
+ return null;
22
+ }
23
+
24
+ return (
25
+ <TypographyStylesProvider p="lg">
26
+ {reasoningContent && (
27
+ <ReasoningSection
28
+ content={reasoningContent}
29
+ isGenerating={isGenerating}
30
+ />
31
+ )}
32
+ {!isGenerating && mainContent && (
33
+ <MarkdownRenderer
34
+ content={mainContent}
35
+ enableCopy={enableCopy}
36
+ className={className}
37
+ />
38
+ )}
39
+ </TypographyStylesProvider>
40
+ );
41
+ }
client/components/AiResponse/LoadingModelContent.tsx ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Card, Group, Progress, Stack, Text } from "@mantine/core";
2
+
3
+ export default function LoadingModelContent({
4
+ modelLoadingProgress,
5
+ modelSizeInMegabytes,
6
+ }: {
7
+ modelLoadingProgress: number;
8
+ modelSizeInMegabytes: number;
9
+ }) {
10
+ const isLoadingStarting = modelLoadingProgress === 0;
11
+ const isLoadingComplete = modelLoadingProgress === 100;
12
+ const percent =
13
+ isLoadingComplete || isLoadingStarting ? 100 : modelLoadingProgress;
14
+ const strokeColor = percent === 100 ? "#52c41a" : "#3385ff";
15
+ const downloadedSize = (modelSizeInMegabytes * modelLoadingProgress) / 100;
16
+ const sizeText = `${downloadedSize.toFixed(0)} MB / ${modelSizeInMegabytes.toFixed(0)} MB`;
17
+
18
+ return (
19
+ <Card withBorder shadow="sm" radius="md">
20
+ <Card.Section withBorder inheritPadding py="xs">
21
+ <Text fw={500}>Loading AI...</Text>
22
+ </Card.Section>
23
+ <Card.Section withBorder inheritPadding py="md">
24
+ <Stack gap="xs">
25
+ <Progress color={strokeColor} value={percent} animated />
26
+ {!isLoadingStarting && (
27
+ <Group justify="space-between">
28
+ <Text size="sm" c="dimmed">
29
+ {sizeText}
30
+ </Text>
31
+ <Text size="sm" c="dimmed">
32
+ {percent.toFixed(1)}%
33
+ </Text>
34
+ </Group>
35
+ )}
36
+ </Stack>
37
+ </Card.Section>
38
+ </Card>
39
+ );
40
+ }
client/components/AiResponse/MarkdownRenderer.tsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CodeHighlight } from "@mantine/code-highlight";
2
+ import { Blockquote, Box, Code, Divider, Text } from "@mantine/core";
3
+ import React from "react";
4
+ import { ErrorBoundary } from "react-error-boundary";
5
+ import Markdown from "react-markdown";
6
+ import rehypeExternalLinks from "rehype-external-links";
7
+ import remarkGfm from "remark-gfm";
8
+ import ExpandableLink from "./ExpandableLink";
9
+
10
+ interface MarkdownRendererProps {
11
+ content: string;
12
+ enableCopy?: boolean;
13
+ className?: string;
14
+ }
15
+
16
+ export default function MarkdownRenderer({
17
+ content,
18
+ enableCopy = true,
19
+ className = "",
20
+ }: MarkdownRendererProps) {
21
+ if (!content) {
22
+ return null;
23
+ }
24
+
25
+ const unwrapParagraphs = (children: React.ReactNode) => {
26
+ return React.Children.map(children, (child) => {
27
+ if (React.isValidElement(child) && child.type === "p") {
28
+ return (child.props as { children: React.ReactNode }).children;
29
+ }
30
+ return child;
31
+ });
32
+ };
33
+
34
+ return (
35
+ <Box className={className}>
36
+ <Markdown
37
+ remarkPlugins={[remarkGfm]}
38
+ rehypePlugins={[
39
+ [
40
+ rehypeExternalLinks,
41
+ { target: "_blank", rel: ["nofollow", "noopener", "noreferrer"] },
42
+ ],
43
+ ]}
44
+ components={{
45
+ a(props) {
46
+ const { href, children } = props;
47
+ return (
48
+ <ExpandableLink href={href || ""}>{children}</ExpandableLink>
49
+ );
50
+ },
51
+ li(props) {
52
+ const { children } = props;
53
+ return <li>{unwrapParagraphs(children)}</li>;
54
+ },
55
+ hr() {
56
+ return <Divider variant="dashed" my="md" />;
57
+ },
58
+ pre(props) {
59
+ return <>{props.children}</>;
60
+ },
61
+ blockquote(props) {
62
+ const { children } = props;
63
+ return (
64
+ <Blockquote>
65
+ <Text size="md">{unwrapParagraphs(children)}</Text>
66
+ </Blockquote>
67
+ );
68
+ },
69
+ code(props) {
70
+ const { children, className, node } = props;
71
+ const codeContent = children?.toString().replace(/\n$/, "") ?? "";
72
+ let language = "text";
73
+
74
+ if (className) {
75
+ const languageMatch = /language-(\w+)/.exec(className);
76
+ if (languageMatch) language = languageMatch[1];
77
+ }
78
+
79
+ if (
80
+ language === "text" &&
81
+ node?.position?.end.line === node?.position?.start.line
82
+ ) {
83
+ return <Code>{codeContent}</Code>;
84
+ }
85
+
86
+ return (
87
+ <ErrorBoundary fallback={<Code block>{codeContent}</Code>}>
88
+ <CodeHighlight
89
+ code={codeContent}
90
+ language={language}
91
+ radius="md"
92
+ withCopyButton={enableCopy}
93
+ mb="xs"
94
+ />
95
+ </ErrorBoundary>
96
+ );
97
+ },
98
+ }}
99
+ >
100
+ {content}
101
+ </Markdown>
102
+ </Box>
103
+ );
104
+ }
client/components/AiResponse/MessageList.tsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ActionIcon, Group, Paper, Stack, Tooltip } from "@mantine/core";
2
+ import { IconPencil, IconRefresh } from "@tabler/icons-react";
3
+ import { memo } from "react";
4
+ import type { ChatMessage } from "@/modules/types";
5
+ import FormattedMarkdown from "./FormattedMarkdown";
6
+
7
+ interface MessageListProps {
8
+ messages: ChatMessage[];
9
+ onEditMessage: (absoluteIndex: number) => void;
10
+ onRegenerate: () => void;
11
+ isGenerating: boolean;
12
+ }
13
+
14
+ interface MessageProps {
15
+ message: ChatMessage;
16
+ index: number;
17
+ absoluteIndex: number;
18
+ isLastAssistant: boolean;
19
+ isGenerating: boolean;
20
+ onEditMessage: (absoluteIndex: number) => void;
21
+ onRegenerate: () => void;
22
+ }
23
+
24
+ const Message = memo(
25
+ ({
26
+ message,
27
+ index,
28
+ absoluteIndex,
29
+ isLastAssistant,
30
+ isGenerating,
31
+ onEditMessage,
32
+ onRegenerate,
33
+ }: MessageProps) => {
34
+ const canEdit = message.role === "user";
35
+ const canRegenerate = isLastAssistant && message.role === "assistant";
36
+ const iconSize = 16;
37
+ const iconVariant: "subtle" = "subtle";
38
+
39
+ return (
40
+ <Group
41
+ gap="xs"
42
+ align="center"
43
+ w="100%"
44
+ justify={message.role === "user" ? "flex-end" : "flex-start"}
45
+ >
46
+ {canEdit && (
47
+ <Tooltip label="Edit" withArrow position="right" openDelay={300}>
48
+ <ActionIcon
49
+ aria-label="Edit message"
50
+ color="gray"
51
+ variant={iconVariant}
52
+ disabled={isGenerating}
53
+ onClick={() => onEditMessage(absoluteIndex)}
54
+ >
55
+ <IconPencil size={iconSize} />
56
+ </ActionIcon>
57
+ </Tooltip>
58
+ )}
59
+
60
+ <Paper
61
+ key={`${message.role}-${index}`}
62
+ shadow="xs"
63
+ radius="xl"
64
+ p="sm"
65
+ style={{ flex: 1, overflow: "auto" }}
66
+ >
67
+ <FormattedMarkdown>{message.content}</FormattedMarkdown>
68
+ </Paper>
69
+
70
+ {canRegenerate && (
71
+ <Tooltip
72
+ label="Re-generate response"
73
+ withArrow
74
+ position="left"
75
+ openDelay={300}
76
+ >
77
+ <ActionIcon
78
+ aria-label="Re-generate response"
79
+ color="gray"
80
+ variant={iconVariant}
81
+ disabled={isGenerating}
82
+ onClick={() => onRegenerate()}
83
+ >
84
+ <IconRefresh size={iconSize} />
85
+ </ActionIcon>
86
+ </Tooltip>
87
+ )}
88
+ </Group>
89
+ );
90
+ },
91
+ );
92
+
93
+ const MessageList = memo(function MessageList({
94
+ messages,
95
+ onEditMessage,
96
+ onRegenerate,
97
+ isGenerating,
98
+ }: MessageListProps) {
99
+ if (messages.length <= 2) return null;
100
+
101
+ return (
102
+ <Stack gap="md">
103
+ {messages
104
+ .slice(2)
105
+ .filter((message) => message.content.length > 0)
106
+ .map((message, index) => {
107
+ const absoluteIndex = index + 2;
108
+ const isLastAssistant =
109
+ absoluteIndex === messages.length - 1 &&
110
+ message.role === "assistant";
111
+ return (
112
+ <Message
113
+ key={`${message.role}-${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
+ }
client/components/AiResponse/ReasoningSection.tsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Box,
3
+ Collapse,
4
+ Flex,
5
+ Group,
6
+ Loader,
7
+ Text,
8
+ UnstyledButton,
9
+ } from "@mantine/core";
10
+ import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
11
+ import { useEffect, useState } from "react";
12
+ import MarkdownRenderer from "./MarkdownRenderer";
13
+
14
+ interface ReasoningSectionProps {
15
+ content: string;
16
+ isGenerating?: boolean;
17
+ }
18
+
19
+ export default function ReasoningSection({
20
+ content,
21
+ isGenerating = false,
22
+ }: ReasoningSectionProps) {
23
+ const [isOpen, setIsOpen] = useState(isGenerating);
24
+
25
+ useEffect(() => {
26
+ if (isGenerating) {
27
+ setIsOpen(true);
28
+ } else {
29
+ setIsOpen(false);
30
+ }
31
+ }, [isGenerating]);
32
+
33
+ return (
34
+ <Box mb="xs">
35
+ <UnstyledButton
36
+ onClick={() => setIsOpen(!isOpen)}
37
+ style={(theme) => ({
38
+ width: "100%",
39
+ padding: theme.spacing.xs,
40
+ borderStartStartRadius: theme.radius.md,
41
+ borderStartEndRadius: theme.radius.md,
42
+ borderEndEndRadius: !isOpen ? theme.radius.md : 0,
43
+ borderEndStartRadius: !isOpen ? theme.radius.md : 0,
44
+ backgroundColor: theme.colors.dark[8],
45
+ "&:hover": {
46
+ backgroundColor: theme.colors.dark[5],
47
+ },
48
+ cursor: isOpen ? "zoom-out" : "zoom-in",
49
+ })}
50
+ >
51
+ <Group gap={3}>
52
+ {isOpen ? (
53
+ <IconChevronDown size={16} />
54
+ ) : (
55
+ <IconChevronRight size={16} />
56
+ )}
57
+ <Flex align="center" gap={6}>
58
+ <Text size="sm" c="dimmed" fs="italic" span>
59
+ {isGenerating ? "Thinking" : "Thought Process"}
60
+ </Text>
61
+ {isGenerating && <Loader size="sm" color="dimmed" type="dots" />}
62
+ </Flex>
63
+ </Group>
64
+ </UnstyledButton>
65
+ <Collapse in={isOpen}>
66
+ <Box
67
+ style={(theme) => ({
68
+ backgroundColor: theme.colors.dark[8],
69
+ padding: theme.spacing.sm,
70
+ borderBottomLeftRadius: theme.radius.md,
71
+ borderBottomRightRadius: theme.radius.md,
72
+ })}
73
+ >
74
+ <MarkdownRenderer content={content} enableCopy={false} />
75
+ </Box>
76
+ </Collapse>
77
+ </Box>
78
+ );
79
+ }
client/components/AiResponse/WebLlmModelSelect.tsx ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type ComboboxItem, Select } from "@mantine/core";
2
+ import { prebuiltAppConfig } from "@mlc-ai/web-llm";
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { isF16Supported } from "@/modules/webGpu";
5
+
6
+ export default function WebLlmModelSelect({
7
+ value,
8
+ onChange,
9
+ }: {
10
+ value: string;
11
+ onChange: (value: string) => void;
12
+ }) {
13
+ const [webGpuModels] = useState<ComboboxItem[]>(() => {
14
+ const models = prebuiltAppConfig.model_list
15
+ .filter((model) => {
16
+ const isSmall = isSmallModel(model);
17
+ const suffix = getModelSuffix(isF16Supported, isSmall);
18
+ return model.model_id.endsWith(suffix);
19
+ })
20
+ .sort((a, b) => (a.vram_required_MB ?? 0) - (b.vram_required_MB ?? 0))
21
+ .map((model) => {
22
+ const modelSizeInMegabytes =
23
+ Math.round(model.vram_required_MB ?? 0) || "N/A";
24
+ const isSmall = isSmallModel(model);
25
+ const suffix = getModelSuffix(isF16Supported, isSmall);
26
+ const modelName = model.model_id.replace(suffix, "");
27
+
28
+ return {
29
+ label: `${modelSizeInMegabytes} MB • ${modelName}`,
30
+ value: model.model_id,
31
+ };
32
+ });
33
+
34
+ return models;
35
+ });
36
+
37
+ useEffect(() => {
38
+ const isCurrentModelValid = webGpuModels.some(
39
+ (model) => model.value === value,
40
+ );
41
+
42
+ if (!isCurrentModelValid && webGpuModels.length > 0) {
43
+ onChange(webGpuModels[0].value);
44
+ }
45
+ }, [onChange, webGpuModels, value]);
46
+
47
+ const handleChange = useCallback(
48
+ (value: string | null) => {
49
+ if (value) onChange(value);
50
+ },
51
+ [onChange],
52
+ );
53
+
54
+ return (
55
+ <Select
56
+ value={value}
57
+ onChange={handleChange}
58
+ label="AI Model"
59
+ description="Select the model to use for AI responses."
60
+ data={webGpuModels}
61
+ allowDeselect={false}
62
+ searchable
63
+ />
64
+ );
65
+ }
66
+
67
+ type ModelConfig = (typeof prebuiltAppConfig.model_list)[number];
68
+
69
+ const smallModels = ["SmolLM2-135M", "SmolLM2-360M"] as const;
70
+
71
+ function isSmallModel(model: ModelConfig) {
72
+ return smallModels.some((smallModel) =>
73
+ model.model_id.startsWith(smallModel),
74
+ );
75
+ }
76
+
77
+ function getModelSuffix(isF16: boolean, isSmall: boolean) {
78
+ if (isSmall) return isF16 ? "-q0f16-MLC" : "-q0f32-MLC";
79
+
80
+ return isF16 ? "-q4f16_1-MLC" : "-q4f32_1-MLC";
81
+ }
client/components/AiResponse/WllamaModelSelect.tsx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type ComboboxItem, Select } from "@mantine/core";
2
+ import { useEffect, useState } from "react";
3
+ import { wllamaModels } from "@/modules/wllama";
4
+
5
+ export default function WllamaModelSelect({
6
+ value,
7
+ onChange,
8
+ }: {
9
+ value: string;
10
+ onChange: (value: string) => void;
11
+ }) {
12
+ const [wllamaModelOptions] = useState<ComboboxItem[]>(
13
+ Object.entries(wllamaModels)
14
+ .sort(([, a], [, b]) => a.fileSizeInMegabytes - b.fileSizeInMegabytes)
15
+ .map(([value, { label, fileSizeInMegabytes }]) => ({
16
+ label: `${fileSizeInMegabytes} MB • ${label}`,
17
+ value,
18
+ })),
19
+ );
20
+
21
+ useEffect(() => {
22
+ const isCurrentModelValid = wllamaModelOptions.some(
23
+ (model) => model.value === value,
24
+ );
25
+
26
+ if (!isCurrentModelValid && wllamaModelOptions.length > 0) {
27
+ onChange(wllamaModelOptions[0].value);
28
+ }
29
+ }, [onChange, wllamaModelOptions, value]);
30
+
31
+ return (
32
+ <Select
33
+ value={value}
34
+ onChange={(value) => value && onChange(value)}
35
+ label="AI Model"
36
+ description="Select the model to use for AI responses."
37
+ data={wllamaModelOptions}
38
+ allowDeselect={false}
39
+ searchable
40
+ />
41
+ );
42
+ }
client/components/AiResponse/hooks/useReasoningContent.test.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { renderHook } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { useReasoningContent } from "./useReasoningContent";
4
+
5
+ const expectEmptyState = (result: {
6
+ current: ReturnType<typeof useReasoningContent>;
7
+ }) => {
8
+ expect(result.current.reasoningContent).toBe("");
9
+ expect(result.current.mainContent).toBe("");
10
+ expect(result.current.isGenerating).toBe(false);
11
+ };
12
+
13
+ describe("useReasoningContent hook", () => {
14
+ describe("parsing reasoning content from markdown markers", () => {
15
+ it("should extract reasoning content between start and end markers", () => {
16
+ const { result } = renderHook(() =>
17
+ useReasoningContent(
18
+ "<think>Let me think about this</think>\nHere is the answer.",
19
+ ),
20
+ );
21
+
22
+ expect(result.current.reasoningContent).toBe("Let me think about this");
23
+ expect(result.current.mainContent).toBe("\nHere is the answer.");
24
+ expect(result.current.isGenerating).toBe(false);
25
+ });
26
+
27
+ it("should handle empty text", () => {
28
+ const { result } = renderHook(() => useReasoningContent(""));
29
+ expectEmptyState(result);
30
+ });
31
+
32
+ it("should return text as main content when no markers present", () => {
33
+ const { result } = renderHook(() =>
34
+ useReasoningContent("This is a normal response."),
35
+ );
36
+
37
+ expect(result.current.reasoningContent).toBe("");
38
+ expect(result.current.mainContent).toBe("This is a normal response.");
39
+ expect(result.current.isGenerating).toBe(false);
40
+ });
41
+
42
+ it("should detect generating state when end marker is missing", () => {
43
+ const { result } = renderHook(() =>
44
+ useReasoningContent("<think>I'm still thinking"),
45
+ );
46
+
47
+ expect(result.current.reasoningContent).toBe("I'm still thinking");
48
+ expect(result.current.mainContent).toBe("");
49
+ expect(result.current.isGenerating).toBe(true);
50
+ });
51
+
52
+ it("should handle whitespace-only content", () => {
53
+ const { result } = renderHook(() => useReasoningContent(" "));
54
+ expectEmptyState(result);
55
+ });
56
+
57
+ it("should handle null/undefined content gracefully", () => {
58
+ const { result } = renderHook(() =>
59
+ useReasoningContent(null as unknown as string),
60
+ );
61
+ expectEmptyState(result);
62
+ });
63
+ });
64
+
65
+ describe("UI state management for reasoning section", () => {
66
+ it("should provide correct isGenerating state for accordion title", () => {
67
+ const streamingState = renderHook(() =>
68
+ useReasoningContent("<think>Currently thinking..."),
69
+ );
70
+
71
+ expect(streamingState.result.current.isGenerating).toBe(true);
72
+
73
+ const completedState = renderHook(() =>
74
+ useReasoningContent(
75
+ "<think>Thought process completed</think>\nHere is the answer.",
76
+ ),
77
+ );
78
+
79
+ expect(completedState.result.current.isGenerating).toBe(false);
80
+ });
81
+
82
+ it("should handle transition from streaming to completed state", () => {
83
+ const initial = renderHook(() =>
84
+ useReasoningContent("<think>Building response..."),
85
+ );
86
+ expect(initial.result.current.isGenerating).toBe(true);
87
+
88
+ const transitioned = renderHook(() =>
89
+ useReasoningContent(
90
+ "<think>Response built.</think>\nFinal answer here.",
91
+ ),
92
+ );
93
+ expect(transitioned.result.current.isGenerating).toBe(false);
94
+ });
95
+ });
96
+ });
client/components/AiResponse/hooks/useReasoningContent.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { usePubSub } from "create-pubsub/react";
2
+ import { useCallback } from "react";
3
+ import { settingsPubSub } from "@/modules/pubSub";
4
+
5
+ /**
6
+ * Hook for extracting reasoning content from AI responses
7
+ * @param text - The full text response from the AI
8
+ * @returns Object containing separated reasoning and main content
9
+ */
10
+ export function useReasoningContent(text: string) {
11
+ const [settings] = usePubSub(settingsPubSub);
12
+
13
+ const extractReasoningAndMainContent = useCallback(
14
+ (text: string, startMarker: string, endMarker: string) => {
15
+ if (!text)
16
+ return { reasoningContent: "", mainContent: "", isGenerating: false };
17
+
18
+ const trimmedText = text.trim();
19
+
20
+ if (!trimmedText.startsWith(startMarker))
21
+ return { reasoningContent: "", mainContent: text, isGenerating: false };
22
+
23
+ const startIndex = trimmedText.indexOf(startMarker);
24
+ const endIndex = trimmedText.indexOf(endMarker);
25
+
26
+ if (endIndex === -1) {
27
+ return {
28
+ reasoningContent: trimmedText.slice(startIndex + startMarker.length),
29
+ mainContent: "",
30
+ isGenerating: true,
31
+ };
32
+ }
33
+
34
+ return {
35
+ reasoningContent: trimmedText.slice(
36
+ startIndex + startMarker.length,
37
+ endIndex,
38
+ ),
39
+ mainContent: trimmedText.slice(endIndex + endMarker.length),
40
+ isGenerating: false,
41
+ };
42
+ },
43
+ [],
44
+ );
45
+
46
+ if (text && text.trim() === "") {
47
+ return {
48
+ reasoningContent: "",
49
+ mainContent: "",
50
+ isGenerating: false,
51
+ };
52
+ }
53
+
54
+ if (!text) {
55
+ return {
56
+ reasoningContent: "",
57
+ mainContent: "",
58
+ isGenerating: false,
59
+ };
60
+ }
61
+
62
+ const result = extractReasoningAndMainContent(
63
+ text,
64
+ settings.reasoningStartMarker,
65
+ settings.reasoningEndMarker,
66
+ );
67
+
68
+ return result;
69
+ }
client/components/Analytics/SearchStats.tsx ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Badge,
3
+ Card,
4
+ Center,
5
+ Group,
6
+ Progress,
7
+ SimpleGrid,
8
+ Stack,
9
+ Text,
10
+ ThemeIcon,
11
+ Title,
12
+ } from "@mantine/core";
13
+ import { IconSearch } from "@tabler/icons-react";
14
+ import { useMemo } from "react";
15
+ import { useSearchHistory } from "@/hooks/useSearchHistory";
16
+ import type { SearchEntry } from "@/modules/history";
17
+ import { formatRelativeTime } from "@/modules/stringFormatters";
18
+
19
+ interface SearchStatsProps {
20
+ period?: "today" | "week" | "month" | "all";
21
+ compact?: boolean;
22
+ }
23
+
24
+ interface StatsData {
25
+ totalSearches: number;
26
+ avgPerDay: number;
27
+ mostActiveHour: number;
28
+ topSources: { source: string; count: number; percentage: number }[];
29
+ recentActivity: SearchEntry[];
30
+ searchTrends: { date: string; count: number }[];
31
+ }
32
+
33
+ export default function SearchStats({
34
+ period = "week",
35
+ compact = false,
36
+ }: SearchStatsProps) {
37
+ const { recentSearches, isLoading } = useSearchHistory({ limit: 1000 });
38
+
39
+ const stats = useMemo((): StatsData => {
40
+ if (!recentSearches.length) {
41
+ return {
42
+ totalSearches: 0,
43
+ avgPerDay: 0,
44
+ mostActiveHour: 0,
45
+ topSources: [],
46
+ recentActivity: [],
47
+ searchTrends: [],
48
+ };
49
+ }
50
+
51
+ const now = new Date();
52
+ const filterDate = new Date();
53
+
54
+ switch (period) {
55
+ case "today":
56
+ filterDate.setHours(0, 0, 0, 0);
57
+ break;
58
+ case "week":
59
+ filterDate.setDate(now.getDate() - 7);
60
+ break;
61
+ case "month":
62
+ filterDate.setDate(now.getDate() - 30);
63
+ break;
64
+ default:
65
+ filterDate.setFullYear(2000);
66
+ }
67
+
68
+ const filteredSearches = recentSearches.filter(
69
+ (search) => search.timestamp >= filterDate.getTime(),
70
+ );
71
+
72
+ const totalSearches = filteredSearches.length;
73
+
74
+ const dateCounts = new Map<string, number>();
75
+ filteredSearches.forEach((search) => {
76
+ const date = new Date(search.timestamp).toISOString().split("T")[0];
77
+ dateCounts.set(date, (dateCounts.get(date) || 0) + 1);
78
+ });
79
+
80
+ const uniqueDays = dateCounts.size;
81
+ const avgPerDay =
82
+ uniqueDays > 0 ? Math.round(totalSearches / uniqueDays) : 0;
83
+
84
+ const hourCounts = new Array(24).fill(0);
85
+ filteredSearches.forEach((search) => {
86
+ const hour = new Date(search.timestamp).getHours();
87
+ hourCounts[hour]++;
88
+ });
89
+ const mostActiveHour = hourCounts.indexOf(Math.max(...hourCounts));
90
+
91
+ const sourceCounts = filteredSearches.reduce(
92
+ (acc, search) => {
93
+ if (compact && search.source?.toLowerCase() === "user") {
94
+ return acc;
95
+ }
96
+ const source = search.source || "unknown";
97
+ acc[source] = (acc[source] || 0) + 1;
98
+ return acc;
99
+ },
100
+ {} as Record<string, number>,
101
+ );
102
+
103
+ const sourcesTotal = Object.values(sourceCounts).reduce(
104
+ (sum, n) => sum + n,
105
+ 0,
106
+ );
107
+ const topSources = Object.entries(sourceCounts)
108
+ .map(([source, count]) => ({
109
+ source: source.charAt(0).toUpperCase() + source.slice(1),
110
+ count,
111
+ percentage:
112
+ sourcesTotal > 0 ? Math.round((count / sourcesTotal) * 100) : 0,
113
+ }))
114
+ .sort((a, b) => b.count - a.count);
115
+
116
+ const recentActivity = filteredSearches
117
+ .sort((a, b) => b.timestamp - a.timestamp)
118
+ .slice(0, 10);
119
+
120
+ const searchTrends = Array.from(dateCounts.entries())
121
+ .map(([date, count]) => ({ date, count }))
122
+ .sort((a, b) => a.date.localeCompare(b.date));
123
+
124
+ return {
125
+ totalSearches,
126
+ avgPerDay,
127
+ mostActiveHour,
128
+ topSources,
129
+ recentActivity,
130
+ searchTrends,
131
+ };
132
+ }, [recentSearches, period, compact]);
133
+
134
+ const getSourceColor = (source: string) => {
135
+ const colors = {
136
+ User: "blue",
137
+ Followup: "green",
138
+ Suggestion: "orange",
139
+ Unknown: "gray",
140
+ };
141
+ return colors[source as keyof typeof colors] || "gray";
142
+ };
143
+
144
+ const formatHour = (hour: number) => {
145
+ const period = hour >= 12 ? "PM" : "AM";
146
+ const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
147
+ return `${displayHour}:00 ${period}`;
148
+ };
149
+
150
+ if (isLoading) {
151
+ return (
152
+ <Card withBorder>
153
+ <Center h={200}>
154
+ <Text c="dimmed">Loading analytics...</Text>
155
+ </Center>
156
+ </Card>
157
+ );
158
+ }
159
+
160
+ if (stats.totalSearches === 0) {
161
+ return (
162
+ <Card withBorder>
163
+ <Center h={200}>
164
+ <Stack align="center" gap="xs">
165
+ <ThemeIcon size="xl" variant="light" color="gray">
166
+ <IconSearch size={24} />
167
+ </ThemeIcon>
168
+ <Text c="dimmed">No search data available</Text>
169
+ <Text size="sm" c="dimmed">
170
+ Start searching to see analytics
171
+ </Text>
172
+ </Stack>
173
+ </Center>
174
+ </Card>
175
+ );
176
+ }
177
+
178
+ function MetricCard({
179
+ title,
180
+ value,
181
+ }: {
182
+ title: string;
183
+ value: string | number;
184
+ }) {
185
+ return (
186
+ <Card withBorder p={compact ? "sm" : "md"}>
187
+ <Stack gap="xs" align="flex-start">
188
+ <Text size="xs" tt="uppercase" fw={700} c="dimmed">
189
+ {title}
190
+ </Text>
191
+ <Text fw={700} size={compact ? "lg" : "xl"}>
192
+ {value}
193
+ </Text>
194
+ </Stack>
195
+ </Card>
196
+ );
197
+ }
198
+
199
+ return (
200
+ <Stack gap={compact ? "sm" : "md"}>
201
+ <SimpleGrid cols={compact ? 2 : { base: 1, sm: 2, lg: 4 }}>
202
+ <MetricCard
203
+ title="Total Searches"
204
+ value={stats.totalSearches.toLocaleString()}
205
+ />
206
+ <MetricCard title="Daily Average" value={stats.avgPerDay} />
207
+ <MetricCard
208
+ title="Most Active Hour"
209
+ value={formatHour(stats.mostActiveHour)}
210
+ />
211
+ <MetricCard
212
+ title="Last Search"
213
+ value={
214
+ stats.recentActivity.length > 0
215
+ ? formatRelativeTime(stats.recentActivity[0].timestamp)
216
+ : "Never"
217
+ }
218
+ />
219
+ </SimpleGrid>
220
+
221
+ <SimpleGrid cols={compact ? 1 : { base: 1, md: 2 }}>
222
+ {!(compact && stats.topSources.length === 0) && (
223
+ <Card withBorder>
224
+ <Card.Section p={compact ? "sm" : "md"}>
225
+ <Title order={compact ? 5 : 4} mb={compact ? "xs" : "md"}>
226
+ Search Sources
227
+ </Title>
228
+
229
+ {stats.topSources.length > 0 ? (
230
+ <Stack gap="xs">
231
+ {stats.topSources.map((source) => (
232
+ <Group key={source.source} justify="space-between">
233
+ <Group gap="xs">
234
+ <Badge
235
+ color={getSourceColor(source.source)}
236
+ variant="dot"
237
+ size="sm"
238
+ >
239
+ {source.source}
240
+ </Badge>
241
+ <Text size={compact ? "xs" : "sm"}>
242
+ {source.count} searches
243
+ </Text>
244
+ </Group>
245
+
246
+ <Group gap="xs">
247
+ <Progress
248
+ value={source.percentage}
249
+ size="sm"
250
+ w={compact ? 40 : 60}
251
+ color={getSourceColor(source.source)}
252
+ />
253
+ <Text size="xs" c="dimmed" w={30}>
254
+ {source.percentage}%
255
+ </Text>
256
+ </Group>
257
+ </Group>
258
+ ))}
259
+ </Stack>
260
+ ) : (
261
+ <Text c="dimmed" size="sm">
262
+ No data available
263
+ </Text>
264
+ )}
265
+ </Card.Section>
266
+ </Card>
267
+ )}
268
+ </SimpleGrid>
269
+
270
+ {stats.searchTrends.length > 1 && (
271
+ <Card withBorder>
272
+ <Card.Section p="md">
273
+ <Title order={4} mb="md">
274
+ Recent activity
275
+ </Title>
276
+ <Stack gap="xs" mt="md">
277
+ {stats.searchTrends.slice(-7).map((trend) => {
278
+ const maxCount = Math.max(
279
+ ...stats.searchTrends.map((t) => t.count),
280
+ );
281
+ const percentage =
282
+ maxCount > 0 ? (trend.count / maxCount) * 100 : 0;
283
+
284
+ return (
285
+ <Group key={trend.date} justify="space-between">
286
+ <Text size="xs" c="dimmed" w={80}>
287
+ {new Date(trend.date).toLocaleDateString("en", {
288
+ month: "short",
289
+ day: "numeric",
290
+ })}
291
+ </Text>
292
+
293
+ <Group gap="xs" style={{ flex: 1 }}>
294
+ <Progress
295
+ value={percentage}
296
+ size="sm"
297
+ style={{ flex: 1 }}
298
+ color="blue"
299
+ />
300
+ <Text size="xs" w={20}>
301
+ {trend.count}
302
+ </Text>
303
+ </Group>
304
+ </Group>
305
+ );
306
+ })}
307
+ </Stack>
308
+ </Card.Section>
309
+ </Card>
310
+ )}
311
+ </Stack>
312
+ );
313
+ }
client/components/App/App.tsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Center,
3
+ Container,
4
+ Loader,
5
+ MantineProvider,
6
+ Stack,
7
+ Text,
8
+ } from "@mantine/core";
9
+ import { Route, Switch } from "wouter";
10
+ import "@mantine/core/styles.css";
11
+ import { Notifications } from "@mantine/notifications";
12
+ import { usePubSub } from "create-pubsub/react";
13
+ import { lazy, useEffect, useState } from "react";
14
+ import { addLogEntry } from "@/modules/logEntries";
15
+ import { settingsPubSub } from "@/modules/pubSub";
16
+ import { defaultSettings } from "@/modules/settings";
17
+ import "@mantine/notifications/styles.css";
18
+ import { verifyStoredAccessKey } from "@/modules/accessKey";
19
+ import MainPage from "../Pages/Main/MainPage";
20
+
21
+ const AccessPage = lazy(() => import("../Pages/AccessPage"));
22
+
23
+ /**
24
+ * Main application component with access key validation and routing
25
+ */
26
+ function App() {
27
+ useInitializeSettings();
28
+ const { hasValidatedAccessKey, isCheckingStoredKey, setValidatedAccessKey } =
29
+ useAccessKeyValidation();
30
+
31
+ return (
32
+ <MantineProvider defaultColorScheme="dark">
33
+ {isCheckingStoredKey ? (
34
+ <Container h="100vh">
35
+ <Center h="100vh">
36
+ <Stack align="center">
37
+ <Loader />
38
+ <Text>Verifying access...</Text>
39
+ </Stack>
40
+ </Center>
41
+ </Container>
42
+ ) : (
43
+ <>
44
+ <Notifications />
45
+ <Switch>
46
+ <Route path="/">
47
+ {hasValidatedAccessKey ? (
48
+ <MainPage />
49
+ ) : (
50
+ <AccessPage
51
+ onAccessKeyValid={() => setValidatedAccessKey(true)}
52
+ />
53
+ )}
54
+ </Route>
55
+ </Switch>
56
+ </>
57
+ )}
58
+ </MantineProvider>
59
+ );
60
+ }
61
+
62
+ export default App;
63
+
64
+ /**
65
+ * A custom React hook that initializes the application settings.
66
+ *
67
+ * @returns The initialized settings object.
68
+ *
69
+ * @remarks
70
+ * This hook uses the `usePubSub` hook to access and update the settings state.
71
+ * It initializes the settings by merging the default settings with any existing settings.
72
+ * The initialization is performed once when the component mounts.
73
+ */
74
+ function useInitializeSettings() {
75
+ const [settings, setSettings] = usePubSub(settingsPubSub);
76
+ const [state, setState] = useState({
77
+ settingsInitialized: false,
78
+ });
79
+
80
+ useEffect(() => {
81
+ if (state.settingsInitialized) return;
82
+
83
+ setSettings({ ...defaultSettings, ...settings });
84
+
85
+ setState({ settingsInitialized: true });
86
+
87
+ addLogEntry("Settings initialized");
88
+ }, [settings, setSettings, state.settingsInitialized]);
89
+
90
+ return settings;
91
+ }
92
+
93
+ /**
94
+ * A custom React hook that validates the stored access key on mount.
95
+ *
96
+ * @returns An object containing the validation state and loading state
97
+ */
98
+ function useAccessKeyValidation() {
99
+ const [state, setState] = useState(() => ({
100
+ hasValidatedAccessKey: !VITE_ACCESS_KEYS_ENABLED,
101
+ isCheckingStoredKey: VITE_ACCESS_KEYS_ENABLED,
102
+ }));
103
+
104
+ useEffect(() => {
105
+ if (!VITE_ACCESS_KEYS_ENABLED) return;
106
+
107
+ async function checkStoredAccessKey() {
108
+ const isValid = await verifyStoredAccessKey();
109
+ if (isValid)
110
+ setState((prev) => ({ ...prev, hasValidatedAccessKey: true }));
111
+ setState((prev) => ({ ...prev, isCheckingStoredKey: false }));
112
+ }
113
+
114
+ checkStoredAccessKey();
115
+ }, []);
116
+
117
+ return {
118
+ hasValidatedAccessKey: state.hasValidatedAccessKey,
119
+ isCheckingStoredKey: state.isCheckingStoredKey,
120
+ setValidatedAccessKey: (value: boolean) =>
121
+ setState((prev) => ({ ...prev, hasValidatedAccessKey: value })),
122
+ };
123
+ }
client/components/Logs/LogsModal.tsx ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Alert,
3
+ Button,
4
+ Center,
5
+ CloseButton,
6
+ Group,
7
+ Modal,
8
+ Pagination,
9
+ Table,
10
+ TextInput,
11
+ Tooltip,
12
+ } from "@mantine/core";
13
+ import { IconInfoCircle, IconSearch } from "@tabler/icons-react";
14
+ import { usePubSub } from "create-pubsub/react";
15
+ import { useCallback, useEffect, useMemo, useState } from "react";
16
+ import { logEntriesPubSub } from "@/modules/logEntries";
17
+
18
+ export default function LogsModal({
19
+ opened,
20
+ onClose,
21
+ }: {
22
+ opened: boolean;
23
+ onClose: () => void;
24
+ }) {
25
+ const [logEntries] = usePubSub(logEntriesPubSub);
26
+
27
+ const [page, setPage] = useState(1);
28
+ const [filterText, setFilterText] = useState("");
29
+
30
+ const logEntriesPerPage = 5;
31
+
32
+ const filteredLogEntries = useMemo(() => {
33
+ if (!filterText) return logEntries;
34
+ const lowerCaseFilter = filterText.toLowerCase();
35
+ return logEntries.filter((entry) =>
36
+ entry.message.toLowerCase().includes(lowerCaseFilter),
37
+ );
38
+ }, [logEntries, filterText]);
39
+
40
+ const logEntriesFromCurrentPage = useMemo(
41
+ () =>
42
+ filteredLogEntries.slice(
43
+ (page - 1) * logEntriesPerPage,
44
+ page * logEntriesPerPage,
45
+ ),
46
+ [filteredLogEntries, page],
47
+ );
48
+
49
+ useEffect(() => {
50
+ void filterText;
51
+ setPage(1);
52
+ }, [filterText]);
53
+
54
+ const downloadLogsAsJson = useCallback(() => {
55
+ const jsonString = JSON.stringify(logEntries, null, 2);
56
+ const blob = new Blob([jsonString], { type: "application/json" });
57
+ const url = URL.createObjectURL(blob);
58
+ const link = document.createElement("a");
59
+ link.href = url;
60
+ link.download = "logs.json";
61
+ document.body.appendChild(link);
62
+ link.click();
63
+ document.body.removeChild(link);
64
+ URL.revokeObjectURL(url);
65
+ }, [logEntries]);
66
+
67
+ return (
68
+ <Modal opened={opened} onClose={onClose} size="xl" title="Logs">
69
+ <Alert variant="light" color="blue" icon={<IconInfoCircle />} mb="md">
70
+ <Group justify="space-between" align="center">
71
+ <span>
72
+ This information is stored solely in your browser for personal use.
73
+ It isn't sent automatically and is retained for debugging purposes
74
+ should you need to{" "}
75
+ <a
76
+ href="https://github.com/felladrin/MiniSearch/issues/new?labels=bug&template=bug_report.yml"
77
+ target="_blank"
78
+ rel="noopener noreferrer"
79
+ >
80
+ report a bug
81
+ </a>
82
+ .
83
+ </span>
84
+ <Button onClick={downloadLogsAsJson} size="xs" data-autofocus>
85
+ Download Logs
86
+ </Button>
87
+ </Group>
88
+ </Alert>
89
+ <TextInput
90
+ placeholder="Filter logs..."
91
+ mb="md"
92
+ leftSection={<IconSearch size={16} />}
93
+ value={filterText}
94
+ onChange={(event) => setFilterText(event.currentTarget.value)}
95
+ rightSection={
96
+ filterText ? (
97
+ <Tooltip label="Clear filter" withArrow>
98
+ <CloseButton
99
+ size="sm"
100
+ onClick={() => setFilterText("")}
101
+ aria-label="Clear filter"
102
+ />
103
+ </Tooltip>
104
+ ) : null
105
+ }
106
+ />
107
+ <Table striped highlightOnHover withTableBorder>
108
+ <Table.Thead>
109
+ <Table.Tr>
110
+ <Table.Th style={{ width: 80 }}>Time</Table.Th>
111
+ <Table.Th>Message</Table.Th>
112
+ </Table.Tr>
113
+ </Table.Thead>
114
+ <Table.Tbody>
115
+ {logEntriesFromCurrentPage.map((entry) => (
116
+ <Table.Tr key={entry.timestamp}>
117
+ <Table.Td>
118
+ {new Date(entry.timestamp).toLocaleTimeString()}
119
+ </Table.Td>
120
+ <Table.Td>{entry.message}</Table.Td>
121
+ </Table.Tr>
122
+ ))}
123
+ </Table.Tbody>
124
+ </Table>
125
+ <Center>
126
+ <Pagination
127
+ total={Math.ceil(filteredLogEntries.length / logEntriesPerPage)}
128
+ value={page}
129
+ onChange={setPage}
130
+ size="sm"
131
+ mt="md"
132
+ />
133
+ </Center>
134
+ </Modal>
135
+ );
136
+ }
client/components/Logs/ShowLogsButton.tsx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button, Center, Loader, Stack, Text } from "@mantine/core";
2
+ import { lazy, Suspense, useState } from "react";
3
+ import { addLogEntry } from "@/modules/logEntries";
4
+
5
+ const LogsModal = lazy(() => import("./LogsModal"));
6
+
7
+ export default function ShowLogsButton() {
8
+ const [isLogsModalOpen, setLogsModalOpen] = useState(false);
9
+
10
+ const handleShowLogsButtonClick = () => {
11
+ addLogEntry("User opened the logs modal");
12
+ setLogsModalOpen(true);
13
+ };
14
+
15
+ const handleCloseLogsButtonClick = () => {
16
+ addLogEntry("User closed the logs modal");
17
+ setLogsModalOpen(false);
18
+ };
19
+
20
+ return (
21
+ <Stack gap="xs">
22
+ <Suspense
23
+ fallback={
24
+ <Center>
25
+ <Loader color="gray" type="bars" />
26
+ </Center>
27
+ }
28
+ >
29
+ <Button size="sm" onClick={handleShowLogsButtonClick} variant="default">
30
+ Show logs
31
+ </Button>
32
+ <Text size="xs" c="dimmed">
33
+ View session logs for debugging.
34
+ </Text>
35
+ <LogsModal
36
+ opened={isLogsModalOpen}
37
+ onClose={handleCloseLogsButtonClick}
38
+ />
39
+ </Suspense>
40
+ </Stack>
41
+ );
42
+ }
client/components/Pages/AccessPage.tsx ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button, Container, Stack, TextInput, Title } from "@mantine/core";
2
+ import { type FormEvent, useState } from "react";
3
+ import { validateAccessKey } from "@/modules/accessKey";
4
+ import { addLogEntry } from "@/modules/logEntries";
5
+
6
+ interface AccessPageState {
7
+ accessKey: string;
8
+ error: string;
9
+ }
10
+
11
+ export default function AccessPage({
12
+ onAccessKeyValid,
13
+ }: {
14
+ onAccessKeyValid: () => void;
15
+ }) {
16
+ const [state, setState] = useState<AccessPageState>({
17
+ accessKey: "",
18
+ error: "",
19
+ });
20
+
21
+ const handleSubmit = async (formEvent: FormEvent<HTMLFormElement>) => {
22
+ formEvent.preventDefault();
23
+ setState((prev) => ({ ...prev, error: "" }));
24
+ try {
25
+ const isValid = await validateAccessKey(state.accessKey);
26
+ if (isValid) {
27
+ addLogEntry("Valid access key entered");
28
+ onAccessKeyValid();
29
+ } else {
30
+ setState((prev) => ({ ...prev, error: "Invalid access key" }));
31
+ addLogEntry("Invalid access key attempt");
32
+ }
33
+ } catch (error) {
34
+ setState((prev) => ({ ...prev, error: "Error validating access key" }));
35
+ addLogEntry(`Error validating access key: ${error}`);
36
+ }
37
+ };
38
+
39
+ return (
40
+ <Container size="xs">
41
+ <Stack p="lg" mih="100vh" justify="center">
42
+ <Title order={2} ta="center">
43
+ Access Restricted
44
+ </Title>
45
+ <form onSubmit={handleSubmit}>
46
+ <Stack gap="xs">
47
+ <TextInput
48
+ value={state.accessKey}
49
+ onChange={({ target }) =>
50
+ setState((prev) => ({ ...prev, accessKey: target.value }))
51
+ }
52
+ placeholder="Enter your access key to continue"
53
+ required
54
+ autoFocus
55
+ error={state.error}
56
+ styles={{
57
+ input: {
58
+ textAlign: "center",
59
+ },
60
+ }}
61
+ />
62
+ <Button size="xs" type="submit">
63
+ Submit
64
+ </Button>
65
+ </Stack>
66
+ </form>
67
+ </Stack>
68
+ </Container>
69
+ );
70
+ }
client/components/Pages/Main/MainPage.test.tsx ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ describe("MainPage component logic", () => {
4
+ it("should show results only when query is not empty", () => {
5
+ const isQueryEmpty = (query: string) => query.length === 0;
6
+
7
+ expect(isQueryEmpty("")).toBe(true);
8
+ expect(isQueryEmpty("test query")).toBe(false);
9
+ expect(isQueryEmpty(" ")).toBe(false);
10
+ });
11
+
12
+ it("should determine when to show search results section", () => {
13
+ const shouldShowSearchResults = (
14
+ textSearchState: string,
15
+ imageSearchState: string,
16
+ ) => textSearchState !== "idle" || imageSearchState !== "idle";
17
+
18
+ expect(shouldShowSearchResults("idle", "idle")).toBe(false);
19
+ expect(shouldShowSearchResults("loading", "idle")).toBe(true);
20
+ expect(shouldShowSearchResults("idle", "loading")).toBe(true);
21
+ expect(shouldShowSearchResults("success", "success")).toBe(true);
22
+ });
23
+
24
+ it("should determine when to show AI response section", () => {
25
+ const shouldShowAiResponse = (
26
+ textGenerationState: string,
27
+ showEnableAiResponsePrompt: boolean,
28
+ ) => !showEnableAiResponsePrompt && textGenerationState !== "idle";
29
+
30
+ expect(shouldShowAiResponse("idle", false)).toBe(false);
31
+ expect(shouldShowAiResponse("generating", false)).toBe(true);
32
+ expect(shouldShowAiResponse("generating", true)).toBe(false);
33
+ expect(shouldShowAiResponse("complete", false)).toBe(true);
34
+ });
35
+
36
+ it("should determine when to show enable AI prompt", () => {
37
+ const shouldShowEnablePrompt = (showEnableAiResponsePrompt: boolean) =>
38
+ showEnableAiResponsePrompt;
39
+
40
+ expect(shouldShowEnablePrompt(true)).toBe(true);
41
+ expect(shouldShowEnablePrompt(false)).toBe(false);
42
+ });
43
+
44
+ it("should combine conditions correctly for full page state", () => {
45
+ interface PageState {
46
+ query: string;
47
+ textSearchState: string;
48
+ imageSearchState: string;
49
+ textGenerationState: string;
50
+ showEnableAiResponsePrompt: boolean;
51
+ }
52
+
53
+ const getVisibleSections = (state: PageState) => {
54
+ const isQueryEmpty = state.query.length === 0;
55
+ return {
56
+ showResults:
57
+ !isQueryEmpty &&
58
+ (state.textSearchState !== "idle" ||
59
+ state.imageSearchState !== "idle"),
60
+ showAiResponse:
61
+ !isQueryEmpty &&
62
+ !state.showEnableAiResponsePrompt &&
63
+ state.textGenerationState !== "idle",
64
+ showEnablePrompt: !isQueryEmpty && state.showEnableAiResponsePrompt,
65
+ };
66
+ };
67
+
68
+ expect(
69
+ getVisibleSections({
70
+ query: "",
71
+ textSearchState: "success",
72
+ imageSearchState: "idle",
73
+ textGenerationState: "generating",
74
+ showEnableAiResponsePrompt: false,
75
+ }),
76
+ ).toEqual({
77
+ showResults: false,
78
+ showAiResponse: false,
79
+ showEnablePrompt: false,
80
+ });
81
+
82
+ expect(
83
+ getVisibleSections({
84
+ query: "test",
85
+ textSearchState: "success",
86
+ imageSearchState: "idle",
87
+ textGenerationState: "generating",
88
+ showEnableAiResponsePrompt: false,
89
+ }),
90
+ ).toEqual({
91
+ showResults: true,
92
+ showAiResponse: true,
93
+ showEnablePrompt: false,
94
+ });
95
+
96
+ expect(
97
+ getVisibleSections({
98
+ query: "test",
99
+ textSearchState: "idle",
100
+ imageSearchState: "idle",
101
+ textGenerationState: "idle",
102
+ showEnableAiResponsePrompt: true,
103
+ }),
104
+ ).toEqual({
105
+ showResults: false,
106
+ showAiResponse: false,
107
+ showEnablePrompt: true,
108
+ });
109
+ });
110
+ });
client/components/Pages/Main/MainPage.tsx ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Container, Stack } from "@mantine/core";
2
+ import { usePubSub } from "create-pubsub/react";
3
+ import { lazy, Suspense } from "react";
4
+ import SearchForm from "@/components/Search/Form/SearchForm";
5
+ import {
6
+ imageSearchStatePubSub,
7
+ queryPubSub,
8
+ settingsPubSub,
9
+ textGenerationStatePubSub,
10
+ textSearchStatePubSub,
11
+ } from "@/modules/pubSub";
12
+ import { searchAndRespond } from "@/modules/textGeneration";
13
+ import MenuButton from "./Menu/MenuButton";
14
+
15
+ const AiResponseSection = lazy(
16
+ () => import("@/components/AiResponse/AiResponseSection"),
17
+ );
18
+ const SearchResultsSection = lazy(
19
+ () => import("@/components/Search/Results/SearchResultsSection"),
20
+ );
21
+ const EnableAiResponsePrompt = lazy(
22
+ () => import("@/components/AiResponse/EnableAiResponsePrompt"),
23
+ );
24
+
25
+ export default function MainPage() {
26
+ const [query, updateQuery] = usePubSub(queryPubSub);
27
+ const [textSearchState] = usePubSub(textSearchStatePubSub);
28
+ const [imageSearchState] = usePubSub(imageSearchStatePubSub);
29
+ const [textGenerationState] = usePubSub(textGenerationStatePubSub);
30
+ const [settings, setSettings] = usePubSub(settingsPubSub);
31
+
32
+ const isQueryEmpty = query.length === 0;
33
+
34
+ return (
35
+ <Container>
36
+ <Stack py="md" mih="100vh" justify={isQueryEmpty ? "center" : undefined}>
37
+ <SearchForm
38
+ query={query}
39
+ updateQuery={updateQuery}
40
+ additionalButtons={<MenuButton />}
41
+ />
42
+ {!isQueryEmpty && (
43
+ <>
44
+ {settings.showEnableAiResponsePrompt && (
45
+ <Suspense>
46
+ <EnableAiResponsePrompt
47
+ onAccept={() => {
48
+ setSettings({
49
+ ...settings,
50
+ showEnableAiResponsePrompt: false,
51
+ enableAiResponse: true,
52
+ });
53
+ searchAndRespond();
54
+ }}
55
+ onDecline={() => {
56
+ setSettings({
57
+ ...settings,
58
+ showEnableAiResponsePrompt: false,
59
+ enableAiResponse: false,
60
+ });
61
+ }}
62
+ />
63
+ </Suspense>
64
+ )}
65
+ {!settings.showEnableAiResponsePrompt &&
66
+ textGenerationState !== "idle" && (
67
+ <Suspense>
68
+ <AiResponseSection />
69
+ </Suspense>
70
+ )}
71
+ {(textSearchState !== "idle" || imageSearchState !== "idle") && (
72
+ <Suspense>
73
+ <SearchResultsSection />
74
+ </Suspense>
75
+ )}
76
+ </>
77
+ )}
78
+ </Stack>
79
+ </Container>
80
+ );
81
+ }