diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..d600b6c76dd93f7b2472160d42b2797cae50c8e5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000000000000000000000000000000000..a78447ebf932f1bb3a5b124b472bea8b3a86f80f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +[*] +charset = utf-8 +insert_final_newline = true +end_of_line = lf +indent_style = space +indent_size = 2 +max_line_length = 80 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..41d8a7ebb4319bbd89c16be09fb5936359e14ef4 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# A comma-separated list of access keys. Example: `ACCESS_KEYS="ABC123,JUD71F,HUWE3"`. Leave blank for unrestricted access. +ACCESS_KEYS="" + +# The timeout in hours for access key validation. Set to 0 to require validation on every page load. +ACCESS_KEY_TIMEOUT_HOURS="24" + +# The default model ID for WebLLM with F16 shaders. +WEBLLM_DEFAULT_F16_MODEL_ID="Qwen3-0.6B-q4f16_1-MLC" + +# The default model ID for WebLLM with F32 shaders. +WEBLLM_DEFAULT_F32_MODEL_ID="Qwen3-0.6B-q4f32_1-MLC" + +# The default model ID for Wllama. +WLLAMA_DEFAULT_MODEL_ID="qwen-3-0.6b" + +# 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. +INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL="" + +# The access key for the internal OpenAI compatible API. +INTERNAL_OPENAI_COMPATIBLE_API_KEY="" + +# The model for the internal OpenAI compatible API. +INTERNAL_OPENAI_COMPATIBLE_API_MODEL="" + +# The name of the internal OpenAI compatible API, displayed in the UI. +INTERNAL_OPENAI_COMPATIBLE_API_NAME="Internal API" + +# The type of inference to use by default. The possible values are: +# "browser" -> In the browser (Private) +# "openai" -> Remote Server (API) +# "horde" -> AI Horde (Pre-configured) +# "internal" -> $INTERNAL_OPENAI_COMPATIBLE_API_NAME +DEFAULT_INFERENCE_TYPE="browser" diff --git a/.github/hf-space-config.yml b/.github/hf-space-config.yml new file mode 100644 index 0000000000000000000000000000000000000000..6d9d5b00160bb6ad09b8a92ab74ab6cd0a360b3b --- /dev/null +++ b/.github/hf-space-config.yml @@ -0,0 +1,11 @@ +title: MiniSearch +emoji: πŸ‘ŒπŸ” +colorFrom: yellow +colorTo: yellow +sdk: docker +short_description: Minimalist web-searching app with browser-based AI assistant +pinned: true +custom_headers: + cross-origin-embedder-policy: require-corp + cross-origin-opener-policy: same-origin + cross-origin-resource-policy: cross-origin diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml new file mode 100644 index 0000000000000000000000000000000000000000..c6c9e03c75886daa9d00bda9559cfe37a05629ce --- /dev/null +++ b/.github/workflows/ai-review.yml @@ -0,0 +1,138 @@ +name: Review Pull Request with AI + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: ["main"] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + ai-review: + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-ai-review') }} + continue-on-error: true + runs-on: ubuntu-latest + name: AI Review + permissions: + pull-requests: write + contents: read + timeout-minutes: 30 + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + + - name: Create temporary directory + run: mkdir -p /tmp/pr_review + + - name: Process PR description + id: process_pr + run: | + PR_BODY_ESCAPED=$(cat << 'EOF' + ${{ github.event.pull_request.body }} + EOF + ) + PROCESSED_BODY=$(echo "$PR_BODY_ESCAPED" | sed -E 's/\[(.*?)\]\(.*?\)/\1/g') + echo "$PROCESSED_BODY" > /tmp/pr_review/processed_body.txt + + - name: Fetch branches and output the diff + run: | + git fetch origin main:main + git fetch origin pull/${{ github.event.pull_request.number }}/head:pr-branch + git diff main..pr-branch > /tmp/pr_review/diff.txt + + - name: Prepare review request + id: prepare_request + run: | + PR_TITLE=$(echo "${{ github.event.pull_request.title }}" | sed 's/[()]/\\&/g') + DIFF_CONTENT=$(cat /tmp/pr_review/diff.txt) + PROCESSED_BODY=$(cat /tmp/pr_review/processed_body.txt) + + jq -n \ + --arg model "${{ vars.OPENAI_COMPATIBLE_API_MODEL }}" \ + --arg http_referer "${{ github.event.repository.html_url }}" \ + --arg title "${{ github.event.repository.name }}" \ + --arg system "You are an experienced developer reviewing a Pull Request. You focus only on what matters and provide concise, actionable feedback. + + Review Context: + Repository Name: \"${{ github.event.repository.name }}\" + Repository Description: \"${{ github.event.repository.description }}\" + Branch: \"${{ github.event.pull_request.head.ref }}\" + PR Title: \"$PR_TITLE\" + + Guidelines: + 1. Only comment on issues that: + - Could cause bugs or security issues + - Significantly impact performance + - Make the code harder to maintain + - Violate critical best practices + + 2. For each issue: + - Point to the specific line/file + - Explain why it's a problem + - Suggest a concrete fix + + 3. Praise exceptional solutions briefly, only if truly innovative + + 4. Skip commenting on: + - Minor style issues + - Obvious changes + - Working code that could be marginally improved + - Things that are just personal preference + + Remember: + Less is more. If the code is good and working, just say so, with a short message." \ + --arg user "This is the description of the pull request: + \`\`\`markdown + $PROCESSED_BODY + \`\`\` + + And here is the diff of the changes, for you to review: + \`\`\`diff + $DIFF_CONTENT + \`\`\`" \ + '{ + "model": $model, + "messages": [ + {"role": "system", "content": $system}, + {"role": "user", "content": $user} + ], + "temperature": 0.7, + "top_p": 0.9 + }' > /tmp/pr_review/request.json + - name: Get AI Review + id: ai_review + run: | + RESPONSE=$(curl -s ${{ vars.OPENAI_COMPATIBLE_API_BASE_URL }}/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${{ secrets.OPENAI_COMPATIBLE_API_KEY }}" \ + -d @/tmp/pr_review/request.json) + + # Check for errors in the response + if echo "$RESPONSE" | jq -e '.object == "error"' > /dev/null; then + echo "Error from API:" >&2 + ERROR_MSG=$(echo "$RESPONSE" | jq -r '.message.detail[0].msg // .message') + echo "$ERROR_MSG" >&2 + exit 1 + fi + + echo "### Review" > /tmp/pr_review/response.txt + echo "" >> /tmp/pr_review/response.txt + echo "$RESPONSE" | jq -r '.choices[0].message.content' >> /tmp/pr_review/response.txt + + - name: Find Comment + uses: peter-evans/find-comment@v4 + id: find_comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: "### Review" + + - name: Post or Update PR Review + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find_comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body-path: /tmp/pr_review/response.txt + edit-mode: replace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..53e17edab1e210515659c02d92d4d1d768b9740f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + build-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run lint + run: npm run lint + + - name: Check formatting + run: npm run format + + - name: Run tests + run: npm test diff --git a/.github/workflows/deploy-to-hugging-face.yml b/.github/workflows/deploy-to-hugging-face.yml new file mode 100644 index 0000000000000000000000000000000000000000..7a5988fc6cc78c2f3b50489d0481ffc754202600 --- /dev/null +++ b/.github/workflows/deploy-to-hugging-face.yml @@ -0,0 +1,18 @@ +name: Deploy to Hugging Face + +on: + workflow_dispatch: + +jobs: + sync-to-hf: + name: Sync to Hugging Face Spaces + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: JacobLinCool/huggingface-sync@v1 + with: + github: ${{ secrets.GITHUB_TOKEN }} + user: ${{ vars.HF_SPACE_OWNER }} + space: ${{ vars.HF_SPACE_NAME }} + token: ${{ secrets.HF_TOKEN }} + configuration: ".github/hf-space-config.yml" diff --git a/.github/workflows/on-pull-request-to-main.yml b/.github/workflows/on-pull-request-to-main.yml new file mode 100644 index 0000000000000000000000000000000000000000..6eae98e615c1c1f2c899a9a5f1d785dd3883ff62 --- /dev/null +++ b/.github/workflows/on-pull-request-to-main.yml @@ -0,0 +1,9 @@ +name: On Pull Request To Main +on: + pull_request: + types: [opened, synchronize, reopened] + branches: ["main"] +jobs: + test-lint-ping: + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-test-lint-ping') }} + uses: ./.github/workflows/reusable-test-lint-ping.yml diff --git a/.github/workflows/on-push-to-main.yml b/.github/workflows/on-push-to-main.yml new file mode 100644 index 0000000000000000000000000000000000000000..8ce693215c4351bab8b54ccac302345e1202ba03 --- /dev/null +++ b/.github/workflows/on-push-to-main.yml @@ -0,0 +1,7 @@ +name: On Push To Main +on: + push: + branches: ["main"] +jobs: + test-lint-ping: + uses: ./.github/workflows/reusable-test-lint-ping.yml diff --git a/.github/workflows/publish-docker-image.yml b/.github/workflows/publish-docker-image.yml new file mode 100644 index 0000000000000000000000000000000000000000..7b1da943adb1cd4d9964000fcba06dff2860a343 --- /dev/null +++ b/.github/workflows/publish-docker-image.yml @@ -0,0 +1,39 @@ +name: Publish Docker Image + +on: + workflow_dispatch: + +jobs: + build-and-push-image: + name: Publish Docker Image to GitHub Packages + runs-on: ubuntu-latest + env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push Docker Image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/reusable-test-lint-ping.yml b/.github/workflows/reusable-test-lint-ping.yml new file mode 100644 index 0000000000000000000000000000000000000000..56cb03bfd06cd195c45dc1e5c276125b2bb5e861 --- /dev/null +++ b/.github/workflows/reusable-test-lint-ping.yml @@ -0,0 +1,25 @@ +on: + workflow_call: +jobs: + check-code-quality: + name: Check Code Quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "lts/*" + cache: "npm" + - run: npm ci --ignore-scripts + - run: npm run lint + check-docker-container: + needs: [check-code-quality] + name: Check Docker Container + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - run: docker compose -f docker-compose.production.yml up -d + - name: Check if main page is available + run: until curl -s -o /dev/null -w "%{http_code}" localhost:7860 | grep 200; do sleep 1; done + timeout-minutes: 1 + - run: docker compose -f docker-compose.production.yml down diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..b3f08f9a965c93dd56cd3f7608b1a31595000fa1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules +.DS_Store +/client/dist +/server/models +.vscode +/vite-build-stats.html +.env +/coverage +.playwright-cli diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000000000000000000000000000000000000..6e4139ac2968b5ea0ba4845d1c9cda9ea2a23679 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm install --no-save @biomejs/biome && npx @biomejs/biome check --write --staged --no-errors-on-unmatched diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000000000000000000000000000000000..80bcbed90c4f2b3d895d5086dc775e1bd8b32b43 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps = true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..9cede968af905f548a6faee9fc175750fe371a10 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,96 @@ +FROM node:lts AS llama-builder + +ARG LLAMA_CPP_RELEASE_TAG="b6604" + +RUN apt-get update && apt-get install -y \ + build-essential \ + cmake \ + ccache \ + git \ + curl + +RUN cd /tmp && \ + git clone https://github.com/ggerganov/llama.cpp.git && \ + cd llama.cpp && \ + git checkout $LLAMA_CPP_RELEASE_TAG && \ + cmake -B build -DGGML_NATIVE=OFF -DLLAMA_CURL=OFF && \ + cmake --build build --config Release -j --target llama-server && \ + mkdir -p /usr/local/lib/llama && \ + 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/ \; + +FROM node:lts + +ENV PORT=7860 +EXPOSE $PORT + +ARG USERNAME=node +ARG HOME_DIR=/home/${USERNAME} +ARG APP_DIR=${HOME_DIR}/app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + python3 \ + python3-venv && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /usr/local/searxng /etc/searxng && \ + chown -R ${USERNAME}:${USERNAME} /usr/local/searxng /etc/searxng && \ + chmod 755 /etc/searxng + +WORKDIR /usr/local/searxng +RUN python3 -m venv searxng-venv && \ + chown -R ${USERNAME}:${USERNAME} /usr/local/searxng/searxng-venv && \ + /usr/local/searxng/searxng-venv/bin/pip install --upgrade pip && \ + /usr/local/searxng/searxng-venv/bin/pip install wheel setuptools pyyaml lxml + +RUN git clone https://github.com/searxng/searxng.git /usr/local/searxng/searxng-src && \ + chown -R ${USERNAME}:${USERNAME} /usr/local/searxng/searxng-src + +ARG SEARXNG_SETTINGS_PATH="/etc/searxng/settings.yml" + +WORKDIR /usr/local/searxng/searxng-src +RUN cp searx/settings.yml $SEARXNG_SETTINGS_PATH && \ + chown ${USERNAME}:${USERNAME} $SEARXNG_SETTINGS_PATH && \ + chmod 644 $SEARXNG_SETTINGS_PATH && \ + sed -i 's/ultrasecretkey/'$(openssl rand -hex 32)'/g' $SEARXNG_SETTINGS_PATH && \ + sed -i 's/- html/- json/' $SEARXNG_SETTINGS_PATH && \ + /usr/local/searxng/searxng-venv/bin/pip install -r requirements.txt && \ + /usr/local/searxng/searxng-venv/bin/pip install --no-build-isolation -e . + +COPY --from=llama-builder /tmp/llama.cpp/build/bin/llama-server /usr/local/bin/ +COPY --from=llama-builder /usr/local/lib/llama/* /usr/local/lib/ +RUN ldconfig /usr/local/lib + +USER ${USERNAME} + +WORKDIR ${APP_DIR} + +ARG ACCESS_KEYS +ARG ACCESS_KEY_TIMEOUT_HOURS +ARG WEBLLM_DEFAULT_F16_MODEL_ID +ARG WEBLLM_DEFAULT_F32_MODEL_ID +ARG WLLAMA_DEFAULT_MODEL_ID +ARG INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL +ARG INTERNAL_OPENAI_COMPATIBLE_API_KEY +ARG INTERNAL_OPENAI_COMPATIBLE_API_MODEL +ARG INTERNAL_OPENAI_COMPATIBLE_API_NAME +ARG DEFAULT_INFERENCE_TYPE +ARG HOST +ARG HMR_PORT +ARG ALLOWED_HOSTS + +COPY --chown=${USERNAME}:${USERNAME} ./package.json ./package-lock.json ./.npmrc ./ + +RUN npm ci + +COPY --chown=${USERNAME}:${USERNAME} . . + +RUN git config --global --add safe.directory ${APP_DIR} && \ + npm run build + +HEALTHCHECK --interval=5m CMD curl -f http://localhost:7860/status || exit 1 + +ENTRYPOINT [ "/bin/sh", "-c" ] + +CMD ["(cd /usr/local/searxng/searxng-src && /usr/local/searxng/searxng-venv/bin/python -m searx.webapp > /dev/null 2>&1) & npm start -- --host"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..49ccc1a8283e7b7fdbcc8f53c3ebc4dce16f620a --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +--- +title: MiniSearch +emoji: πŸ‘ŒπŸ” +colorFrom: yellow +colorTo: yellow +sdk: docker +short_description: Minimalist web-searching app with browser-based AI assistant +pinned: true +custom_headers: + cross-origin-embedder-policy: require-corp + cross-origin-opener-policy: same-origin + cross-origin-resource-policy: cross-origin +--- + +# MiniSearch + +A minimalist web-searching app with an AI assistant that runs directly from your browser. + +Live demo: https://felladrin-minisearch.hf.space + +## Screenshot + +![MiniSearch Screenshot](https://github.com/user-attachments/assets/f8d72a8e-a725-42e9-9358-e6ebade2acb2) + +## Features + +- **Privacy-focused**: [No tracking, no ads, no data collection](https://docs.searxng.org/own-instance.html#how-does-searxng-protect-privacy) +- **Easy to use**: Minimalist yet intuitive interface for all users +- **Cross-platform**: Models run inside the browser, both on desktop and mobile +- **Integrated**: Search from the browser address bar by setting it as the default search engine +- **Efficient**: Models are loaded and cached only when needed +- **Customizable**: Tweakable settings for search results and text generation +- **Open-source**: [The code is available for inspection and contribution at GitHub](https://github.com/felladrin/MiniSearch) + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) + +## Getting started + +Here are the easiest ways to get started with MiniSearch. Pick the one that suits you best. + +**Option 1** - Use [MiniSearch's Docker Image](https://github.com/felladrin/MiniSearch/pkgs/container/minisearch) by running in your terminal: + +```bash +docker run -p 7860:7860 ghcr.io/felladrin/minisearch:main +``` + +**Option 2** - Add MiniSearch's Docker Image to your existing Docker Compose file: + +```yaml +services: + minisearch: + image: ghcr.io/felladrin/minisearch:main + ports: + - "7860:7860" +``` + +**Option 3** - Build from source by [downloading the repository files](https://github.com/felladrin/MiniSearch/archive/refs/heads/main.zip) and running: + +```bash +docker compose -f docker-compose.production.yml up --build +``` + +Once the container is running, open http://localhost:7860 in your browser and start searching! + +## Frequently asked questions [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/felladrin/MiniSearch) + +
+ How do I search via the browser's address bar? +

+ You can set MiniSearch as your browser's address-bar search engine using the pattern http://localhost:7860/?q=%s, in which your search term replaces %s. +

+
+ +
+ How do I search via Raycast? +

+ You can add this Quicklink to Raycast, so typing your query will open MiniSearch with the search results. You can also edit it to point to your own domain. +

+ image +
+ +
+ Can I use custom models via OpenAI-Compatible API? +

+ Yes! For this, open the Menu and change the "AI Processing Location" to Remote server (API). Then configure the Base URL, and optionally set an API Key and a Model to use. +

+
+ +
+ How do I restrict the access to my MiniSearch instance via password? +

+ Create a .env file and set a value for ACCESS_KEYS. Then reset the MiniSearch docker container. +

+

+ For example, if you to set the password to PepperoniPizza, then this is what you should add to your .env:
+ ACCESS_KEYS="PepperoniPizza" +

+

+ You can find more examples in the .env.example file. +

+
+ +
+ 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? +

Yes! In MiniSearch, we call this text-generation feature "Internal OpenAI-Compatible API". To use this it:

+
    +
  1. Set up your OpenAI-Compatible API endpoint by configuring the following environment variables in your .env file: +
      +
    • INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL: The base URL for your API
    • +
    • INTERNAL_OPENAI_COMPATIBLE_API_KEY: Your API access key
    • +
    • INTERNAL_OPENAI_COMPATIBLE_API_MODEL: The model to use
    • +
    • INTERNAL_OPENAI_COMPATIBLE_API_NAME: The name to display in the UI
    • +
    +
  2. +
  3. Restart MiniSearch server.
  4. +
  5. In the MiniSearch menu, select the new option (named as per your INTERNAL_OPENAI_COMPATIBLE_API_NAME setting) from the "AI Processing Location" dropdown.
  6. +
+
+ +
+ How can I contribute to the development of this tool? +

Fork this repository and clone it. Then, start the development server by running the following command:

+

docker compose up

+

Make your changes, push them to your fork, and open a pull request! All contributions are welcome!

+
diff --git a/agents.md b/agents.md new file mode 100644 index 0000000000000000000000000000000000000000..be9e7259be83b5df9bb47d55ee17066f2588df63 --- /dev/null +++ b/agents.md @@ -0,0 +1,167 @@ +# MiniSearch Agent Guidelines + +This is your navigation hub. Start here, follow the links, and return when you need orientation. + +## Before You Start + +**New to this codebase?** Read in this order: +1. `docs/quick-start.md` - Get it running +2. `docs/overview.md` - Understand the system +3. `docs/project-structure.md` - Navigate the code + +**Making changes?** Check: +- `docs/coding-conventions.md` - Code style +- `docs/development-commands.md` - Available commands +- `docs/pull-requests.md` - How to submit + +## Repository Map + +### Getting Started +- **`docs/quick-start.md`** - Installation, first run, verification +- **`docs/overview.md`** - System architecture and data flow +- **`docs/project-structure.md`** - Directory layout and component organization + +### Configuration & Setup +- **`docs/configuration.md`** - Environment variables and settings reference +- **`docs/security.md`** - Access control, privacy, and security model + +### Core Functionality +- **`docs/ai-integration.md`** - AI inference types (WebLLM, Wllama, OpenAI, AI Horde, Internal) +- **`docs/ui-components.md`** - Component architecture and PubSub patterns +- **`docs/search-history.md`** - History database schema and management +- **`docs/conversation-memory.md`** - Token budgeting and rolling summaries + +### Development +- **`docs/development-commands.md`** - Docker, npm, and testing commands +- **`docs/coding-conventions.md`** - Style guide and patterns +- **`docs/pull-requests.md`** - PR process and merge philosophy +- **`docs/core-technologies.md`** - Technology stack and dependencies +- **`docs/design.md`** - UI/UX design principles + +## Agent Decision Tree + +``` +Need to: +β”œβ”€β”€ Add a feature? +β”‚ β”œβ”€β”€ UI component β†’ docs/ui-components.md +β”‚ β”œβ”€β”€ AI integration β†’ docs/ai-integration.md +β”‚ β”œβ”€β”€ Search functionality β†’ client/modules/search.ts +β”‚ └── Settings option β†’ docs/configuration.md +β”œβ”€β”€ Fix a bug? +β”‚ β”œβ”€β”€ UI issue β†’ Check component + PubSub channels +β”‚ β”œβ”€β”€ AI not working β†’ docs/ai-integration.md + browser console +β”‚ β”œβ”€β”€ Search failing β†’ Check SearXNG + server hooks +β”‚ └── Build error β†’ docs/development-commands.md +β”œβ”€β”€ Configure deployment? +β”‚ β”œβ”€β”€ Environment variables β†’ docs/configuration.md +β”‚ β”œβ”€β”€ Access control β†’ docs/security.md +β”‚ └── Docker setup β†’ docs/overview.md +└── Understand data flow? + β”œβ”€β”€ Search flow β†’ client/modules/search.ts + β”œβ”€β”€ AI generation β†’ client/modules/textGeneration.ts + β”œβ”€β”€ State management β†’ docs/ui-components.md + └── History/Chat β†’ docs/search-history.md + docs/conversation-memory.md +``` + +## Key Files Reference + +### Entry Points +- `client/main.tsx` - React app initialization +- `vite.config.ts` - Vite dev server with hooks +- `Dockerfile` - Multi-stage container build + +### Core Modules (Business Logic) +- `client/modules/search.ts` - Search orchestration and caching +- `client/modules/textGeneration.ts` - AI response flow +- `client/modules/pubSub.ts` - All PubSub channels +- `client/modules/settings.ts` - Settings management +- `client/modules/history.ts` - Search history database + +### Server-Side +- `server/searchEndpointServerHook.ts` - `/search` endpoints +- `server/internalApiEndpointServerHook.ts` - `/inference` proxy +- `server/webSearchService.ts` - SearXNG integration +- `server/rerankerService.ts` - Local result reranking + +### Components (Key) +- `client/components/App/` - Application shell +- `client/components/Search/Form/` - Search input +- `client/components/Search/Results/` - Results display +- `client/components/AiResponse/` - AI response + chat +- `client/components/Pages/Main/Menu/` - Settings drawers + +## Common Tasks Quick Reference + +### Add a new AI model +1. Add to `client/modules/wllama.ts` or WebLLM registry +2. Update `docs/ai-integration.md` +3. Update `docs/configuration.md` defaults + +### Add a new setting +1. Add to `client/modules/settings.ts` default object +2. Add UI in `client/components/Pages/Main/Menu/` +3. Update `docs/configuration.md` settings table + +### Modify search behavior +1. Edit `client/modules/search.ts` +2. Update `server/webSearchService.ts` if server-side changes needed +3. Check `server/rerankerService.ts` if reranking affected + +### Fix UI state issues +1. Check PubSub channels in `client/modules/pubSub.ts` +2. Verify component subscriptions in `docs/ui-components.md` +3. Ensure proper state updates in business logic modules + +### Analyze test coverage +1. Run `npm run test:coverage` to generate reports +2. Check `coverage/coverage-summary.json` for quick metrics +3. See `docs/development-commands.md` for full coverage analysis guide + +## Quality Gates + +Before any change: +```bash +docker compose exec development-server npm run lint +``` + +This runs: +- Biome (formatting/linting) +- TypeScript (type checking) +- ts-prune (dead code detection) +- jscpd (copy-paste detection) +- dpdm (circular dependency detection) +- Custom architectural linter + +## Agent-First Principles + +**Repository as System of Record:** +- All knowledge lives in versioned docs/ structure +- This file is your entry point - start here +- Follow links, don't assume - verify in code + +**Context Efficiency:** +- Use this map to navigate quickly +- Return to this file when context drifts +- Follow the decision tree for common tasks + +**Architecture & Boundaries:** +- Respect PubSub boundaries - don't cross concerns +- Client vs server - keep them separate +- Feature-based organization - one folder per feature + +**Documentation Maintenance:** +- Update these docs when you learn something new +- Add cross-references when linking concepts +- Keep examples current with actual code + +## Technology Stack + +React + TypeScript + Mantine UI v8, with privacy-first architecture. +See `docs/core-technologies.md` for complete dependency list and selection criteria. + +## Need Help? + +1. Check relevant doc in `docs/` +2. Read the module code in `client/modules/` or `server/` +3. Look at similar existing implementations +4. Run `npm run lint` to validate changes diff --git a/biome.json b/biome.json new file mode 100644 index 0000000000000000000000000000000000000000..dbdd0b33769e0e57f2164a86835a24b19f7bd505 --- /dev/null +++ b/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/latest/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/client/components/AiResponse/AiModelDownloadAllowanceContent.tsx b/client/components/AiResponse/AiModelDownloadAllowanceContent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..48fbb9ce81045bc0cd3ba2e5f99b66cfb7e30c5c --- /dev/null +++ b/client/components/AiResponse/AiModelDownloadAllowanceContent.tsx @@ -0,0 +1,62 @@ +import { Alert, Button, Group, Text } from "@mantine/core"; +import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react"; +import { usePubSub } from "create-pubsub/react"; +import { useState } from "react"; +import { addLogEntry } from "@/modules/logEntries"; +import { settingsPubSub } from "@/modules/pubSub"; + +export default function AiModelDownloadAllowanceContent() { + const [settings, setSettings] = usePubSub(settingsPubSub); + const [hasDeniedDownload, setDeniedDownload] = useState(false); + + const handleAccept = () => { + setSettings({ + ...settings, + allowAiModelDownload: true, + }); + addLogEntry("User allowed the AI model download"); + }; + + const handleDecline = () => { + setDeniedDownload(true); + addLogEntry("User denied the AI model download"); + }; + + return hasDeniedDownload ? null : ( + } + > + + To obtain AI responses, a language model needs to be downloaded to your + browser. Enabling this option lets the app store it and load it + instantly on subsequent uses. + + + Please note that the download size ranges from 100 MB to 4 GB, depending + on the model you select in the Menu, so it's best to avoid using mobile + data for this. + + + + + + + ); +} diff --git a/client/components/AiResponse/AiResponseContent.tsx b/client/components/AiResponse/AiResponseContent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e0805a44314e74b1665d93f6249baaa10a27644a --- /dev/null +++ b/client/components/AiResponse/AiResponseContent.tsx @@ -0,0 +1,219 @@ +import { + ActionIcon, + Alert, + Badge, + Box, + Card, + Group, + ScrollArea, + Text, + Tooltip, +} from "@mantine/core"; +import { + IconArrowsMaximize, + IconArrowsMinimize, + IconHandStop, + IconInfoCircle, + IconRefresh, + IconVolume2, +} from "@tabler/icons-react"; +import type { PublishFunction } from "create-pubsub"; +import { usePubSub } from "create-pubsub/react"; +import { type ReactNode, useMemo, useState } from "react"; +import { addLogEntry } from "@/modules/logEntries"; +import { settingsPubSub } from "@/modules/pubSub"; +import { searchAndRespond } from "@/modules/textGeneration"; +import CopyIconButton from "./CopyIconButton"; +import FormattedMarkdown from "./FormattedMarkdown"; + +export default function AiResponseContent({ + textGenerationState, + response, + setTextGenerationState, +}: { + textGenerationState: string; + response: string; + setTextGenerationState: PublishFunction< + | "failed" + | "awaitingSearchResults" + | "preparingToGenerate" + | "idle" + | "loadingModel" + | "generating" + | "interrupted" + | "completed" + >; +}) { + const [settings, setSettings] = usePubSub(settingsPubSub); + const [isSpeaking, setIsSpeaking] = useState(false); + + const ConditionalScrollArea = useMemo( + () => + ({ children }: { children: ReactNode }) => { + return settings.enableAiResponseScrolling ? ( + + {children} + + ) : ( + {children} + ); + }, + [settings.enableAiResponseScrolling], + ); + + function speakResponse(text: string) { + if (isSpeaking) { + self.speechSynthesis.cancel(); + setIsSpeaking(false); + return; + } + + const prepareTextForSpeech = (textToClean: string) => { + const withoutReasoning = textToClean.replace( + new RegExp( + `${settings.reasoningStartMarker}[\\s\\S]*?${settings.reasoningEndMarker}`, + "g", + ), + "", + ); + const withoutLinks = withoutReasoning.replace( + /\[([^\]]+)\]\([^)]+\)/g, + "($1)", + ); + const withoutMarkdown = withoutLinks.replace(/[#*`_~[\]]/g, ""); + return withoutMarkdown.trim(); + }; + + const utterance = new SpeechSynthesisUtterance(prepareTextForSpeech(text)); + + const voices = self.speechSynthesis.getVoices(); + + if (voices.length > 0 && settings.selectedVoiceId) { + const voice = voices.find( + (voice) => voice.voiceURI === settings.selectedVoiceId, + ); + + if (voice) { + utterance.voice = voice; + utterance.lang = voice.lang; + } + } + + utterance.onerror = () => { + addLogEntry("Failed to speak response"); + setIsSpeaking(false); + }; + + utterance.onend = () => setIsSpeaking(false); + + setIsSpeaking(true); + self.speechSynthesis.speak(utterance); + } + + return ( + + + + + + {textGenerationState === "generating" + ? "Generating AI Response..." + : "AI Response"} + + {textGenerationState === "interrupted" && ( + + Interrupted + + )} + + + {textGenerationState === "generating" ? ( + + setTextGenerationState("interrupted")} + variant="subtle" + color="gray" + > + + + + ) : ( + + searchAndRespond()} + variant="subtle" + color="gray" + > + + + + )} + + speakResponse(response)} + variant="subtle" + color={isSpeaking ? "blue" : "gray"} + > + + + + {settings.enableAiResponseScrolling ? ( + + { + setSettings({ + ...settings, + enableAiResponseScrolling: false, + }); + }} + variant="subtle" + color="gray" + > + + + + ) : ( + + { + setSettings({ + ...settings, + enableAiResponseScrolling: true, + }); + }} + variant="subtle" + color="gray" + > + + + + )} + + + + + + + {response} + + {textGenerationState === "failed" && ( + } + > + Could not generate response. Please try refreshing the page. + + )} + + + ); +} diff --git a/client/components/AiResponse/AiResponseSection.tsx b/client/components/AiResponse/AiResponseSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3b4dc3db5bc63b63729d801dbb33826c790c34ae --- /dev/null +++ b/client/components/AiResponse/AiResponseSection.tsx @@ -0,0 +1,101 @@ +import { CodeHighlightAdapterProvider } from "@mantine/code-highlight"; +import { usePubSub } from "create-pubsub/react"; +import { useMemo } from "react"; +import { + chatMessagesPubSub, + isRestoringFromHistoryPubSub, + modelLoadingProgressPubSub, + modelSizeInMegabytesPubSub, + queryPubSub, + responsePubSub, + settingsPubSub, + textGenerationStatePubSub, +} from "@/modules/pubSub"; +import { shikiAdapter } from "@/modules/shiki"; +import "@mantine/code-highlight/styles.css"; +import AiModelDownloadAllowanceContent from "./AiModelDownloadAllowanceContent"; +import AiResponseContent from "./AiResponseContent"; +import ChatInterface from "./ChatInterface"; +import LoadingModelContent from "./LoadingModelContent"; +import PreparingContent from "./PreparingContent"; + +export default function AiResponseSection() { + const [query] = usePubSub(queryPubSub); + const [response] = usePubSub(responsePubSub); + const [textGenerationState, setTextGenerationState] = usePubSub( + textGenerationStatePubSub, + ); + const [modelLoadingProgress] = usePubSub(modelLoadingProgressPubSub); + const [settings] = usePubSub(settingsPubSub); + const [modelSizeInMegabytes] = usePubSub(modelSizeInMegabytesPubSub); + const [chatMessages] = usePubSub(chatMessagesPubSub); + const [isRestoringFromHistory] = usePubSub(isRestoringFromHistoryPubSub); + + return useMemo(() => { + if (!settings.enableAiResponse || textGenerationState === "idle") { + return null; + } + + const generatingStates = [ + "generating", + "interrupted", + "completed", + "failed", + ]; + if (generatingStates.includes(textGenerationState)) { + return ( + + + + {textGenerationState === "completed" && ( + 0 ? chatMessages : undefined + } + suppressInitialFollowUp={isRestoringFromHistory} + /> + )} + + ); + } + + if (textGenerationState === "loadingModel") { + return ( + + ); + } + + if (textGenerationState === "preparingToGenerate") { + return ; + } + + if (textGenerationState === "awaitingSearchResults") { + return ; + } + + if (textGenerationState === "awaitingModelDownloadAllowance") { + return ; + } + + return null; + }, [ + settings.enableAiResponse, + textGenerationState, + response, + query, + chatMessages, + modelLoadingProgress, + modelSizeInMegabytes, + setTextGenerationState, + isRestoringFromHistory, + ]); +} diff --git a/client/components/AiResponse/ChatHeader.tsx b/client/components/AiResponse/ChatHeader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f62bc98185cb401cd8db79b7f2368ad9c1ef42a5 --- /dev/null +++ b/client/components/AiResponse/ChatHeader.tsx @@ -0,0 +1,33 @@ +import { Group, Text } from "@mantine/core"; +import type { ChatMessage } from "@/modules/types"; +import CopyIconButton from "./CopyIconButton"; + +interface ChatHeaderProps { + messages: ChatMessage[]; +} + +function ChatHeader({ messages }: ChatHeaderProps) { + const getChatContent = () => { + return messages + .slice(2) + .map( + (msg, index) => + `${index + 1}. ${msg.role?.toUpperCase()}\n\n${msg.content}`, + ) + .join("\n\n"); + }; + + return ( + + Follow-up questions + {messages.length > 2 && ( + + )} + + ); +} + +export default ChatHeader; diff --git a/client/components/AiResponse/ChatInputArea.tsx b/client/components/AiResponse/ChatInputArea.tsx new file mode 100644 index 0000000000000000000000000000000000000000..25ff4e20ce45e529ceb708d9e56dbc70e1b81d1b --- /dev/null +++ b/client/components/AiResponse/ChatInputArea.tsx @@ -0,0 +1,106 @@ +import { Button, Group, Textarea } from "@mantine/core"; +import { IconSend } from "@tabler/icons-react"; +import { usePubSub } from "create-pubsub/react"; +import { + chatGenerationStatePubSub, + chatInputPubSub, + followUpQuestionPubSub, + isRestoringFromHistoryPubSub, + suppressNextFollowUpPubSub, +} from "@/modules/pubSub"; + +interface ChatInputAreaProps { + onKeyDown: (event: React.KeyboardEvent) => void; + handleSend: (textToSend?: string) => void; +} + +function ChatInputArea({ onKeyDown, handleSend }: ChatInputAreaProps) { + const [input, setInput] = usePubSub(chatInputPubSub); + const [generationState] = usePubSub(chatGenerationStatePubSub); + const [followUpQuestion] = usePubSub(followUpQuestionPubSub); + const [isRestoringFromHistory] = usePubSub(isRestoringFromHistoryPubSub); + const [suppressNextFollowUp] = usePubSub(suppressNextFollowUpPubSub); + + const isGenerating = + generationState.isGeneratingResponse && + !generationState.isGeneratingFollowUpQuestion; + + const defaultPlaceholder = "Anything else you would like to know?"; + const placeholder = + isRestoringFromHistory || suppressNextFollowUp + ? defaultPlaceholder + : followUpQuestion || defaultPlaceholder; + + const onChange = (event: React.ChangeEvent) => { + setInput(event.target.value); + }; + const handleKeyDownWithPlaceholder = ( + event: React.KeyboardEvent, + ) => { + if ( + input.trim() === "" && + followUpQuestion && + !isRestoringFromHistory && + !suppressNextFollowUp + ) { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleSend(followUpQuestion); + return; + } + } + + onKeyDown(event); + }; + + const handleSendWithPlaceholder = () => { + if ( + input.trim() === "" && + followUpQuestion && + !isRestoringFromHistory && + !suppressNextFollowUp + ) { + handleSend(followUpQuestion); + } else { + handleSend(); + } + }; + + return ( + +